diff --git a/package.json b/package.json index a7089589..22fda070 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build:auth": "pnpm -F @artalk/plugin-auth build", "build:plugin-kit": "pnpm -F @artalk/plugin-kit build", "build:eslint-plugin": "pnpm -F eslint-plugin-artalk build", - "build:docs": "pnpm build && pnpm -F=docs-landing build && pnpm -F=docs-swagger swagger:build && pnpm -F=docs build:docs && pnpm patch:docs", + "build:typedoc": "pnpm typedoc", + "build:docs": "pnpm build && pnpm -F=docs-landing build && pnpm -F=docs-swagger swagger:build && pnpm -F=docs build:docs && pnpm patch:docs && pnpm build:typedoc", "patch:docs": "cp -rf docs/landing/dist/* docs/swagger/dist/* docs/docs/.vitepress/dist", "lint:eslint": "eslint .", "lint:prettier": "prettier --check .", @@ -52,6 +53,7 @@ "sass": "1.78.0", "terser": "5.33.0", "tsx": "^4.19.1", + "typedoc": "^0.26.10", "typescript": "5.6.2", "typescript-eslint": "^8.6.0", "vite": "5.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54090b92..f608f815 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: tsx: specifier: ^4.19.1 version: 4.19.1 + typedoc: + specifier: ^0.26.10 + version: 0.26.10(typescript@5.6.2) typescript: specifier: 5.6.2 version: 5.6.2 @@ -3212,6 +3215,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3288,6 +3294,10 @@ packages: mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + marked@14.1.2: resolution: {integrity: sha512-f3r0yqpz31VXiDB/wj9GaOB0a2PRLQl6vJmXiFrniNwjkKdvakqJRULhjFKJpxOchlCRiG5fcacoUZY5Xa6PEQ==} engines: {node: '>= 18'} @@ -3307,6 +3317,9 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -3745,6 +3758,10 @@ packages: psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4362,6 +4379,13 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} + typedoc@0.26.10: + resolution: {integrity: sha512-xLmVKJ8S21t+JeuQLNueebEuTVphx6IrP06CdV7+0WVflUSW3SPmR+h1fnWVdAR/FQePEgsSWCUHXqKKjzuUAw==} + engines: {node: '>= 18'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x + typescript-eslint@8.6.0: resolution: {integrity: sha512-eEhhlxCEpCd4helh3AO1hk0UP2MvbRi9CtIAJTVPQjuSXOOO2jsEacNi4UdcJzZJbeuVg1gMhtZ8UYb+NFYPrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4381,6 +4405,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -8103,6 +8130,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + load-tsconfig@0.2.5: {} local-pkg@0.5.0: @@ -8173,6 +8204,15 @@ snapshots: mark.js@8.11.1: {} + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + marked@14.1.2: {} marked@4.3.0: {} @@ -8195,6 +8235,8 @@ snapshots: mdn-data@2.0.30: optional: true + mdurl@2.0.0: {} + meow@13.2.0: optional: true @@ -8603,6 +8645,8 @@ snapshots: psl@1.9.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} querystringify@2.2.0: {} @@ -9389,6 +9433,15 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 + typedoc@0.26.10(typescript@5.6.2): + dependencies: + lunr: 2.3.9 + markdown-it: 14.1.0 + minimatch: 9.0.5 + shiki: 1.17.7 + typescript: 5.6.2 + yaml: 2.5.1 + typescript-eslint@8.6.0(eslint@9.10.0)(typescript@5.6.2): dependencies: '@typescript-eslint/eslint-plugin': 8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0)(typescript@5.6.2))(eslint@9.10.0)(typescript@5.6.2) @@ -9404,6 +9457,8 @@ snapshots: typescript@5.6.2: {} + uc.micro@2.1.0: {} + ufo@1.5.4: {} uglify-js@3.19.3: diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..fde0bc99 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,6 @@ +{ + "out": "docs/docs/.vitepress/dist/typedoc", + "entryPoints": ["ui/artalk/src/main.ts"], + "tsconfig": "ui/artalk/tsconfig.json", + "logLevel": "Error" +} diff --git a/ui/artalk/src/artalk.ts b/ui/artalk/src/artalk.ts index 63065b55..4cd40ee5 100644 --- a/ui/artalk/src/artalk.ts +++ b/ui/artalk/src/artalk.ts @@ -1,14 +1,19 @@ import './style/main.scss' -import type { EventHandler } from './lib/event-manager' import Context from './context' -import { handelCustomConf, convertApiOptions } from './config' -import Services from './service' +import { handelCustomConf, convertApiOptions, getRootEl } from './config' import * as Stat from './plugins/stat' import { Api } from './api' -import type { TInjectedServices } from './service' -import { GlobalPlugins, PluginOptions, load } from './load' -import type { ArtalkConfigPartial, EventPayloadMap, ArtalkPlugin, ContextApi } from '@/types' +import { GlobalPlugins, PluginOptions, mount } from './mount' +import { ConfigService } from './services/config' +import { EventsService } from './services/events' +import type { + ConfigPartial, + EventPayloadMap, + ArtalkPlugin, + Context as IContext, + EventHandler, +} from '@/types' /** * Artalk @@ -16,25 +21,45 @@ import type { ArtalkConfigPartial, EventPayloadMap, ArtalkPlugin, ContextApi } f * @see https://artalk.js.org */ export default class Artalk { - public ctx!: ContextApi + public ctx: IContext - constructor(conf: ArtalkConfigPartial) { - // Init Config - const handledConf = handelCustomConf(conf, true) + constructor(conf: ConfigPartial) { + // Init Root Element + const $root = getRootEl(conf) + $root.classList.add('artalk') + $root.innerHTML = '' + conf.darkMode == true && $root.classList.add('atk-dark-mode') // Init Context - this.ctx = new Context(handledConf) + const ctx = (this.ctx = new Context($root)) - // Init Services - Object.entries(Services).forEach(([name, initService]) => { - const obj = initService(this.ctx) - obj && this.ctx.inject(name as keyof TInjectedServices, obj) // auto inject deps to ctx - }) + // Init required services + ;(() => { + // Init event manager + EventsService(ctx) + + // Init config service + ConfigService(ctx) + })() + + // Apply local conf first + ctx.updateConf(conf) + + // Trigger created event + ctx.trigger('created') + + // Load plugins and remote config, then mount Artalk + const mountArtalk = async () => { + await mount(conf, ctx) + + // Trigger mounted event + ctx.trigger('mounted') + } if (import.meta.env.DEV && import.meta.env.VITEST) { - global.devLoadArtalk = () => load(this.ctx) + global.devMountArtalk = mountArtalk } else { - load(this.ctx) + mountArtalk() } } @@ -45,13 +70,12 @@ export default class Artalk { /** Get the root element of Artalk */ public getEl() { - return this.ctx.$root + return this.ctx.getEl() } /** Update config of Artalk */ - public update(conf: ArtalkConfigPartial) { + public update(conf: ConfigPartial) { this.ctx.updateConf(conf) - return this } /** Reload comment list of Artalk */ @@ -61,10 +85,7 @@ export default class Artalk { /** Destroy instance of Artalk */ public destroy() { - this.ctx.trigger('unmounted') - while (this.ctx.$root.firstChild) { - this.ctx.$root.removeChild(this.ctx.$root.firstChild) - } + this.ctx.destroy() } /** Add an event listener */ @@ -92,7 +113,7 @@ export default class Artalk { // =========================== /** Init Artalk */ - public static init(conf: ArtalkConfigPartial): Artalk { + public static init(conf: ConfigPartial): Artalk { return new Artalk(conf) } @@ -103,7 +124,7 @@ export default class Artalk { } /** Load count widget */ - public static loadCountWidget(c: ArtalkConfigPartial) { + public static loadCountWidget(c: ConfigPartial) { const conf = handelCustomConf(c, true) Stat.initCountWidget({ @@ -119,14 +140,15 @@ export default class Artalk { // =========================== // Deprecated // =========================== - /** @deprecated Please use `getEl()` instead */ public get $root() { - return this.ctx.$root + console.warn('`$root` is deprecated, please use `getEl()` instead') + return this.getEl() } /** @description Please use `getConf()` instead */ public get conf() { - return this.ctx.getConf() + console.warn('`conf` is deprecated, please use `getConf()` instead') + return this.getConf() } } diff --git a/ui/artalk/src/comment/comment-node.ts b/ui/artalk/src/comment/comment-node.ts index eec375d0..065e634b 100644 --- a/ui/artalk/src/comment/comment-node.ts +++ b/ui/artalk/src/comment/comment-node.ts @@ -6,7 +6,7 @@ import UADetect from '../lib/detect' import CommentUI from './render' import CommentActions from './actions' import $t from '@/i18n' -import type { CommentData, ArtalkConfig, ContextApi } from '@/types' +import type { CommentData, Config, Context } from '@/types' export interface CommentOptions { // Hooks @@ -22,16 +22,16 @@ export interface CommentOptions { voteDown: boolean uaBadge: boolean nestMax: number - gravatar: ArtalkConfig['gravatar'] - heightLimit: ArtalkConfig['heightLimit'] - avatarURLBuilder: ArtalkConfig['avatarURLBuilder'] - scrollRelativeTo: ArtalkConfig['scrollRelativeTo'] - dateFormatter: ArtalkConfig['dateFormatter'] + gravatar: Config['gravatar'] + heightLimit: Config['heightLimit'] + avatarURLBuilder: Config['avatarURLBuilder'] + scrollRelativeTo: Config['scrollRelativeTo'] + dateFormatter: Config['dateFormatter'] // TODO: Move to plugin folder and remove from core getApi: () => Api - replyComment: ContextApi['replyComment'] - editComment: ContextApi['editComment'] + replyComment: Context['replyComment'] + editComment: Context['editComment'] } export default class CommentNode { diff --git a/ui/artalk/src/components/checker/index.ts b/ui/artalk/src/components/checker/index.ts index ed7d4a05..1bd5920c 100644 --- a/ui/artalk/src/components/checker/index.ts +++ b/ui/artalk/src/components/checker/index.ts @@ -3,9 +3,8 @@ import AdminChecker from './admin' import type { Api } from '@/api' import Dialog from '@/components/dialog' import $t from '@/i18n' -import type { ContextApi } from '@/types' -import type User from '@/lib/user' import * as Utils from '@/lib/utils' +import type { UserManager, CheckerManager as ICheckerManager, LayerManager } from '@/types' export interface CheckerCaptchaPayload extends CheckerPayload { img_data?: string @@ -19,8 +18,9 @@ export interface CheckerPayload { } export interface CheckerLauncherOptions { - getCtx: () => ContextApi getApi: () => Api + getLayers: () => LayerManager + getUser: () => UserManager getCaptchaIframeURL: () => string onReload: () => void } @@ -43,25 +43,25 @@ function wrapPromise

(fn: (p: P) => vo } /** - * Checker 发射台 + * Checker Launcher */ -export default class CheckerLauncher { +export class CheckerLauncher implements ICheckerManager { constructor(private opts: CheckerLauncherOptions) {} public checkCaptcha: (payload: CheckerCaptchaPayload) => Promise = wrapPromise((p) => { - this.fire(CaptchaChecker, p, (ctx) => { + this.check(CaptchaChecker, p, (ctx) => { ctx.set('img_data', p.img_data) ctx.set('iframe', p.iframe) }) }) public checkAdmin: (payload: CheckerPayload) => Promise = wrapPromise((p) => { - this.fire(AdminChecker, p) + this.check(AdminChecker, p) }) - public fire(checker: Checker, payload: CheckerPayload, postFire?: (c: CheckerCtx) => void) { + public check(checker: Checker, payload: CheckerPayload, beforeCheck?: (c: CheckerCtx) => void) { // 显示层 - const layer = this.opts.getCtx().get('layerManager').create(`checker-${new Date().getTime()}`) + const layer = this.opts.getLayers().create(`checker-${new Date().getTime()}`) layer.show() const close = () => { @@ -77,7 +77,7 @@ export default class CheckerLauncher { }, get: (key) => checkerStore[key], getOpts: () => this.opts, - getUser: () => this.opts.getCtx().get('user'), + getUser: () => this.opts.getUser(), getApi: () => this.opts.getApi(), hideInteractInput: () => { hideInteractInput = true @@ -93,7 +93,7 @@ export default class CheckerLauncher { }, } - if (postFire) postFire(checkerCtx) + if (beforeCheck) beforeCheck(checkerCtx) // 创建表单 const formEl = Utils.createElement() @@ -202,7 +202,7 @@ export interface CheckerCtx { set(key: K, val: CheckerStore[K]): void getOpts(): CheckerLauncherOptions getApi(): Api - getUser(): User + getUser(): UserManager hideInteractInput(): void triggerSuccess(): void cancel(): void diff --git a/ui/artalk/src/config.ts b/ui/artalk/src/config.ts index 385f9017..009a642a 100644 --- a/ui/artalk/src/config.ts +++ b/ui/artalk/src/config.ts @@ -1,8 +1,8 @@ import type { ApiOptions } from './api/options' import { mergeDeep } from './lib/merge-deep' -import { createApiHandlers } from './api' -import Defaults from './defaults' -import type { ArtalkConfig, ArtalkConfigPartial, ContextApi } from '@/types' +import type { ApiHandlers } from './api' +import { Defaults } from './defaults' +import type { Config, ConfigPartial, UserManager } from '@/types' /** * Handle the custom config which is provided by the user @@ -11,40 +11,28 @@ import type { ArtalkConfig, ArtalkConfigPartial, ContextApi } from '@/types' * @param full - If `full` is `true`, the return value will be the complete config for Artalk instance creation * @returns The config for Artalk instance creation */ -export function handelCustomConf(customConf: ArtalkConfigPartial, full: true): ArtalkConfig -export function handelCustomConf(customConf: ArtalkConfigPartial, full?: false): ArtalkConfigPartial -export function handelCustomConf(customConf: ArtalkConfigPartial, full = false) { - // 合并默认配置 - const conf: ArtalkConfigPartial = full ? mergeDeep(Defaults, customConf) : customConf +export function handelCustomConf(customConf: ConfigPartial, full: true): Config +export function handelCustomConf(customConf: ConfigPartial, full?: false): ConfigPartial +export function handelCustomConf(customConf: ConfigPartial, full = false) { + // Merge default config + const conf: ConfigPartial = full ? mergeDeep(Defaults, customConf) : customConf - // 绑定元素 - if (conf.el && typeof conf.el === 'string') { - try { - const findEl = document.querySelector(conf.el) - if (!findEl) throw Error(`Target element "${conf.el}" was not found.`) - conf.el = findEl - } catch (e) { - console.error(e) - throw new Error('Please check your Artalk `el` config.') - } - } + // Default pageKey + if (conf.pageKey === '') conf.pageKey = `${window.location.pathname}` // @see http://bl.ocks.org/abernier/3070589 - // 默认 pageKey - if (conf.pageKey === '') conf.pageKey = `${window.location.pathname}` // @link http://bl.ocks.org/abernier/3070589 - - // 默认 pageTitle + // Default pageTitle if (conf.pageTitle === '') conf.pageTitle = `${document.title}` - // 服务器配置 + // Server if (conf.server) conf.server = conf.server.replace(/\/$/, '').replace(/\/api\/?$/, '') - // 自适应语言 + // Language auto-detection if (conf.locale === 'auto') conf.locale = navigator.language - // 自动判断启用平铺模式 + // Flat mode auto-detection if (conf.flatMode === 'auto') conf.flatMode = window.matchMedia('(max-width: 768px)').matches - // flatMode + // Change flatMode by nestMax if (typeof conf.nestMax === 'number' && Number(conf.nestMax) <= 1) conf.flatMode = true return conf @@ -56,8 +44,8 @@ export function handelCustomConf(customConf: ArtalkConfigPartial, full = false) * @param conf - The Server response config for control the frontend of Artalk remotely * @returns The config for Artalk instance creation */ -export function handleConfFormServer(conf: ArtalkConfigPartial): ArtalkConfigPartial { - const ExcludedKeys: (keyof ArtalkConfig)[] = [ +export function handleConfFormServer(conf: ConfigPartial): ConfigPartial { + const ExcludedKeys: (keyof Config)[] = [ 'el', 'pageKey', 'pageTitle', @@ -86,6 +74,26 @@ export function handleConfFormServer(conf: ArtalkConfigPartial): ArtalkConfigPar return conf } +/** + * Get the root element of Artalk + * + * @param conf - Artalk config + * @returns The root element of Artalk + */ +export function getRootEl(conf: ConfigPartial): HTMLElement { + let $root: HTMLElement + if (typeof conf.el === 'string') { + const el = document.querySelector(conf.el) + if (!el) throw new Error(`Element "${conf.el}" not found.`) + $root = el + } else if (conf.el instanceof HTMLElement) { + $root = conf.el + } else { + throw new Error('Please provide a valid `el` config for Artalk.') + } + return $root +} + /** * Convert Artalk Config to ApiOptions for Api client * @@ -93,28 +101,24 @@ export function handleConfFormServer(conf: ArtalkConfigPartial): ArtalkConfigPar * @param ctx - If `ctx` not provided, `checkAdmin` and `checkCaptcha` will be disabled * @returns ApiOptions for Api client instance creation */ -export function convertApiOptions(conf: ArtalkConfigPartial, ctx?: ContextApi): ApiOptions { +export function convertApiOptions( + conf: ConfigPartial, + user?: UserManager, + handlers?: ApiHandlers, +): ApiOptions { return { baseURL: `${conf.server}/api/v2`, siteName: conf.site || '', pageKey: conf.pageKey || '', pageTitle: conf.pageTitle || '', timeout: conf.reqTimeout, - getApiToken: () => ctx?.get('user').getData().token, - userInfo: ctx?.get('user').checkHasBasicUserInfo() + getApiToken: () => user?.getData().token, + userInfo: user?.checkHasBasicUserInfo() ? { - name: ctx?.get('user').getData().name, - email: ctx?.get('user').getData().email, + name: user?.getData().name, + email: user?.getData().email, } : undefined, - handlers: ctx?.getApiHandlers(), + handlers, } } - -export function createNewApiHandlers(ctx: ContextApi) { - const h = createApiHandlers() - h.add('need_captcha', (res) => ctx.checkCaptcha(res)) - h.add('need_login', () => ctx.checkAdmin({})) - - return h -} diff --git a/ui/artalk/src/context.ts b/ui/artalk/src/context.ts index 45fe9053..a2f9a804 100644 --- a/ui/artalk/src/context.ts +++ b/ui/artalk/src/context.ts @@ -1,193 +1,206 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -import type { TInjectedServices } from './service' -import { Api, ApiHandlers } from './api' - -import * as marked from './lib/marked' -import { mergeDeep } from './lib/merge-deep' -import { CheckerCaptchaPayload, CheckerPayload } from './components/checker' - -import { DataManager } from './data' -import * as I18n from './i18n' - -import EventManager from './lib/event-manager' -import { convertApiOptions, createNewApiHandlers, handelCustomConf } from './config' -import { watchConf } from './lib/watch-conf' - import type { - ArtalkConfig, - ArtalkConfigPartial, + Config, + ConfigPartial, CommentData, ListFetchParams, - ContextApi, - EventPayloadMap, + Context as IContext, SidebarShowPayload, -} from '@/types' - -// Auto dependency injection -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface Context extends TInjectedServices {} + Services, +} from './types' +import * as I18n from './i18n' +import * as marked from './lib/marked' +import { createInjectionContainer } from './lib/injection' +import type { CheckerCaptchaPayload, CheckerPayload } from './components/checker' /** * Artalk Context */ -class Context implements ContextApi { - /* 运行参数 */ - conf: ArtalkConfig - data: DataManager - $root: HTMLElement - - /* Event Manager */ - private events = new EventManager() - private mounted = false +class Context implements IContext { + private _deps = createInjectionContainer() - constructor(conf: ArtalkConfig) { - this.conf = conf + constructor(private _$root: HTMLElement) {} - this.$root = conf.el as HTMLElement - this.$root.classList.add('artalk') - this.$root.innerHTML = '' - conf.darkMode && this.$root.classList.add('atk-dark-mode') - - this.data = new DataManager(this.events) + getEl(): HTMLElement { + return this._$root + } - this.on('mounted', () => { - this.mounted = true - }) + destroy(): void { + this.trigger('unmounted') + while (this._$root.firstChild) { + this._$root.removeChild(this._$root.firstChild) + } } - inject(depName: string, obj: any) { - this[depName] = obj + // ------------------------------------------------------------------- + // Dependency Injection + // ------------------------------------------------------------------- + provide: IContext['provide'] = (key, impl, deps, opts) => { + this._deps.provide(key, impl, deps, opts) } - get(depName: string) { - return this[depName] + inject: IContext['inject'] = (key) => { + return this._deps.inject(key) } + get = this.inject - getApi() { - return new Api(convertApiOptions(this.conf, this)) + // ------------------------------------------------------------------- + // Event Manager + // ------------------------------------------------------------------- + on: IContext['on'] = (name, handler) => { + this.inject('events').on(name, handler) } - private apiHandlers = null - getApiHandlers() { - if (!this.apiHandlers) this.apiHandlers = createNewApiHandlers(this) - return this.apiHandlers + off: IContext['off'] = (name, handler) => { + this.inject('events').off(name, handler) } - getData() { - return this.data + trigger: IContext['trigger'] = (name, payload) => { + this.inject('events').trigger(name, payload) } - replyComment(commentData: CommentData, $comment: HTMLElement): void { - this.editor.setReply(commentData, $comment) + // ------------------------------------------------------------------- + // Configurations + // ------------------------------------------------------------------- + getConf(): Config { + return this.inject('config').get() } - editComment(commentData: CommentData, $comment: HTMLElement): void { - this.editor.setEditComment(commentData, $comment) + updateConf(conf: ConfigPartial): void { + this.inject('config').update(conf) } - fetch(params: Partial): void { - this.data.fetchComments(params) + watchConf( + keys: T, + effect: (conf: Pick) => void, + ): void { + this.inject('config').watchConf(keys, effect) } - reload(): void { - this.data.fetchComments({ offset: 0 }) + getMarked() { + return marked.getInstance() } - /* List */ - listGotoFirst(): void { - this.events.trigger('list-goto-first') + setDarkMode(darkMode: boolean | 'auto'): void { + this.updateConf({ darkMode }) } - getCommentNodes() { - return this.list.getCommentNodes() + get conf() { + return this.getConf() + } + set conf(val) { + console.error('Cannot set config directly, please call updateConf()') + } + get $root() { + return this.getEl() + } + set $root(val) { + console.error('set $root is prohibited') } - getComments() { - return this.data.getComments() + // ------------------------------------------------------------------- + // I18n: Internationalization + // ------------------------------------------------------------------- + $t(key: I18n.I18nKeys, args: { [key: string]: string } = {}): string { + return I18n.t(key, args) } - getCommentList = this.getCommentNodes - getCommentDataList = this.getComments + // ------------------------------------------------------------------- + // HTTP API Client + // ------------------------------------------------------------------- + getApi() { + return this.inject('api') + } - /* Editor */ - editorShowLoading(): void { - this.editor.showLoading() + getApiHandlers() { + return this.inject('apiHandlers') } - editorHideLoading(): void { - this.editor.hideLoading() + // ------------------------------------------------------------------- + // User Manager + // ------------------------------------------------------------------- + getUser() { + return this.inject('user') } - editorShowNotify(msg, type): void { - this.editor.showNotify(msg, type) + // ------------------------------------------------------------------- + // Data Manager + // ------------------------------------------------------------------- + getData() { + return this.inject('data') } - editorResetState(): void { - this.editor.resetState() + fetch(params: Partial): void { + this.getData().fetchComments(params) } - /* Sidebar */ - showSidebar(payload?: SidebarShowPayload): void { - this.sidebarLayer.show(payload) + reload(): void { + this.getData().fetchComments({ offset: 0 }) } - hideSidebar(): void { - this.sidebarLayer.hide() + // ------------------------------------------------------------------- + // List + // ------------------------------------------------------------------- + listGotoFirst(): void { + this.trigger('list-goto-first') } - /* Checker */ - checkAdmin(payload: CheckerPayload): Promise { - return this.checkerLauncher.checkAdmin(payload) + getCommentList = this.getCommentNodes + getCommentNodes() { + return this.inject('list').getCommentNodes() } - checkCaptcha(payload: CheckerCaptchaPayload): Promise { - return this.checkerLauncher.checkCaptcha(payload) + getCommentDataList = this.getComments + getComments() { + return this.getData().getComments() } - /* Events */ - on(name: any, handler: any) { - this.events.on(name, handler) + // ------------------------------------------------------------------- + // Editor + // ------------------------------------------------------------------- + replyComment(commentData: CommentData, $comment: HTMLElement): void { + this.inject('editor').setReplyComment(commentData, $comment) } - off(name: any, handler: any) { - this.events.off(name, handler) + editComment(commentData: CommentData, $comment: HTMLElement): void { + this.inject('editor').setEditComment(commentData, $comment) } - trigger(name: any, payload?: any) { - this.events.trigger(name, payload) + editorShowLoading(): void { + this.inject('editor').showLoading() } - /* i18n */ - $t(key: I18n.I18nKeys, args: { [key: string]: string } = {}): string { - return I18n.t(key, args) + editorHideLoading(): void { + this.inject('editor').hideLoading() } - setDarkMode(darkMode: boolean | 'auto'): void { - this.updateConf({ darkMode }) + editorShowNotify(msg, type): void { + this.inject('editor').showNotify(msg, type) } - updateConf(nConf: ArtalkConfigPartial): void { - this.conf = mergeDeep(this.conf, handelCustomConf(nConf, false)) - this.mounted && this.events.trigger('updated', this.conf) + editorResetState(): void { + this.inject('editor').resetState() } - getConf(): ArtalkConfig { - return this.conf + // ------------------------------------------------------------------- + // Sidebar + // ------------------------------------------------------------------- + showSidebar(payload?: SidebarShowPayload): void { + this.inject('sidebar').show(payload) } - getEl(): HTMLElement { - return this.$root + hideSidebar(): void { + this.inject('sidebar').hide() } - getMarked() { - return marked.getInstance() + // ------------------------------------------------------------------- + // Checker + // ------------------------------------------------------------------- + checkAdmin(payload: CheckerPayload): Promise { + return this.inject('checkers').checkAdmin(payload) } - watchConf( - keys: T, - effect: (conf: Pick) => void, - ): void { - watchConf(this, keys, effect) + checkCaptcha(payload: CheckerCaptchaPayload): Promise { + return this.inject('checkers').checkCaptcha(payload) } } diff --git a/ui/artalk/src/data.ts b/ui/artalk/src/data.ts index fd28d490..3657160e 100644 --- a/ui/artalk/src/data.ts +++ b/ui/artalk/src/data.ts @@ -1,22 +1,30 @@ -import EventManager from './lib/event-manager' import type { NotifyData, PageData, CommentData, - DataManagerApi, + DataManager as IDataManager, ListFetchParams, ListLastFetchData, - EventPayloadMap, + EventManager, } from '@/types' -export class DataManager implements DataManagerApi { +export class DataManager implements IDataManager { + /** Loading status */ private loading: boolean = false + + /** List last fetch data */ private listLastFetch?: ListLastFetchData - private comments: CommentData[] = [] // Note: 无层级结构 + 无须排列 + + /** Comment list (Flatten list and unordered) */ + private comments: CommentData[] = [] + + /** Notify list */ private notifies: NotifyData[] = [] + + /** Page data */ private page?: PageData - constructor(protected events: EventManager) {} + constructor(protected events: EventManager) {} getLoading() { return this.loading diff --git a/ui/artalk/src/defaults.ts b/ui/artalk/src/defaults.ts index ff3096c5..e2ca3e94 100644 --- a/ui/artalk/src/defaults.ts +++ b/ui/artalk/src/defaults.ts @@ -1,13 +1,6 @@ -import type { ArtalkConfig } from '@/types' +import type { Config } from '@/types' -type RequiredExcept = Required> & Pick -type FunctionKeys = Exclude< - { [K in keyof T]: NonNullable extends (...args: any[]) => any ? K : never }[keyof T], - undefined -> -type ExcludedKeys = FunctionKeys - -const defaults: RequiredExcept = { +export const Defaults: Readonly> = { el: '', pageKey: '', pageTitle: '', @@ -24,15 +17,17 @@ const defaults: RequiredExcept = { nestMax: 2, nestSort: 'DATE_ASC', - emoticons: 'https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json', + emoticons: ARTALK_LITE + ? false + : 'https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json', pageVote: true, - vote: true, + vote: ARTALK_LITE ? false : true, voteDown: false, - uaBadge: true, + uaBadge: ARTALK_LITE ? false : true, listSort: true, - preview: true, + preview: ARTALK_LITE ? false : true, countEl: '.artalk-comment-count', pvEl: '.artalk-pv-count', statPageKeyAttr: 'data-page-key', @@ -54,13 +49,14 @@ const defaults: RequiredExcept = { scrollable: false, }, - pvAdd: true, imgUpload: true, - imgLazyLoad: 'native', + imgLazyLoad: false, reqTimeout: 15000, versionCheck: true, useBackendConf: true, listUnreadHighlight: false, + pvAdd: true, + fetchCommentsOnInit: true, locale: 'en', apiVersion: '', @@ -69,11 +65,9 @@ const defaults: RequiredExcept = { markedOptions: {}, } -if (ARTALK_LITE) { - defaults.vote = false - defaults.uaBadge = false - defaults.emoticons = false - defaults.preview = false -} - -export default defaults +type RequiredExcept = Required> & Pick +type FunctionKeys = Exclude< + { [K in keyof T]: NonNullable extends (...args: any[]) => any ? K : never }[keyof T], + undefined +> +type ExcludedKeys = FunctionKeys diff --git a/ui/artalk/src/editor/editor.ts b/ui/artalk/src/editor/editor.ts index 6a3678d7..f729d76b 100644 --- a/ui/artalk/src/editor/editor.ts +++ b/ui/artalk/src/editor/editor.ts @@ -1,26 +1,24 @@ -import Component from '../lib/component' import * as Ui from '../lib/ui' import marked from '../lib/marked' -import { render, EditorUI } from './ui' -import EditorStateManager from './state' -import type { CommentData, ContextApi, EditorApi } from '@/types' +import { render, type EditorUI } from './ui' +import { EditorStateManager } from './state' +import type { ConfigManager, CommentData, Editor as IEditor, EventManager } from '@/types' +import type { PluginManager } from '@/plugins/editor-kit' + +export interface EditorOptions { + getEvents: () => EventManager + getConf: () => ConfigManager +} -class Editor extends Component implements EditorApi { +export class Editor implements IEditor { + private opts: EditorOptions + private $el: HTMLElement private ui: EditorUI private state: EditorStateManager + private plugins?: PluginManager - getUI() { - return this.ui - } - getPlugs() { - return this.ctx.get('editorPlugs') - } - getState() { - return this.state.get() - } - - constructor(ctx: ContextApi) { - super(ctx) + constructor(opts: EditorOptions) { + this.opts = opts // init editor ui this.ui = render() @@ -30,6 +28,30 @@ class Editor extends Component implements EditorApi { this.state = new EditorStateManager(this) } + getOptions() { + return this.opts + } + + getEl() { + return this.$el + } + + getUI() { + return this.ui + } + + getPlugins() { + return this.plugins + } + + setPlugins(plugins: PluginManager) { + this.plugins = plugins + } + + getState() { + return this.state.get() + } + getHeaderInputEls() { return { name: this.ui.$name, email: this.ui.$email, link: this.ui.$link } } @@ -37,9 +59,9 @@ class Editor extends Component implements EditorApi { getContentFinal() { let content = this.getContentRaw() - // plug hook: final content transformer - const plugs = this.getPlugs() - if (plugs) content = plugs.getTransformedContent(content) + // plugin hook: final content transformer + const plugins = this.getPlugins() + if (plugins) content = plugins.getTransformedContent(content) return content } @@ -56,7 +78,7 @@ class Editor extends Component implements EditorApi { this.ui.$textarea.value = val // plug hook: content updated - this.getPlugs()?.getEvents().trigger('content-updated', val) + this.getPlugins()?.getEvents().trigger('content-updated', val) } insertContent(val: string) { @@ -96,7 +118,7 @@ class Editor extends Component implements EditorApi { this.state.switch('normal') } - setReply(comment: CommentData, $comment: HTMLElement) { + setReplyComment(comment: CommentData, $comment: HTMLElement) { this.state.switch('reply', { comment, $comment }) } @@ -117,13 +139,15 @@ class Editor extends Component implements EditorApi { } submit() { - const next = () => this.ctx.trigger('editor-submit') - if (this.ctx.conf.beforeSubmit) { - this.ctx.conf.beforeSubmit(this, next) + const next = () => { + this.getPlugins()?.getEvents().trigger('editor-submit') + this.opts.getEvents().trigger('editor-submit') + } + const beforeSubmit = this.opts.getConf().get().beforeSubmit + if (beforeSubmit) { + beforeSubmit(this, next) } else { next() } } } - -export default Editor diff --git a/ui/artalk/src/editor/state.ts b/ui/artalk/src/editor/state.ts index 4b7badba..d1b1d8f8 100644 --- a/ui/artalk/src/editor/state.ts +++ b/ui/artalk/src/editor/state.ts @@ -1,9 +1,8 @@ import Mover from '../plugins/editor/mover' -import type Editor from './editor' -import type { EditorState, CommentData } from '@/types' +import type { EditorState, CommentData, Editor } from '@/types' import * as Ui from '@/lib/ui' -export default class EditorStateManager { +export class EditorStateManager { constructor(private editor: Editor) {} private stateCurt: EditorState = 'normal' @@ -27,24 +26,23 @@ export default class EditorStateManager { this.stateUnmountFn = null // move editor back to the initial position - this.editor.getPlugs()?.get(Mover)?.back() + this.editor.getPlugins()?.get(Mover)?.back() } // invoke effect function and save unmount function if (state !== 'normal' && payload) { // move editor position let moveAfterEl = payload.$comment - if (!this.editor.conf.flatMode) + if (!this.editor.getOptions().getConf().get().flatMode) moveAfterEl = moveAfterEl.querySelector('.atk-footer')! - this.editor.getPlugs()?.get(Mover)?.move(moveAfterEl) + this.editor.getPlugins()?.get(Mover)?.move(moveAfterEl) - const $relative = - this.editor.ctx.conf.scrollRelativeTo && this.editor.ctx.conf.scrollRelativeTo() + const $relative = this.editor.getOptions().getConf().get().scrollRelativeTo?.() Ui.scrollIntoView(this.editor.getUI().$el, true, $relative) const plugin = this.editor - .getPlugs() - ?.getPlugs() + .getPlugins() + ?.getPlugins() .find((p) => p.editorStateEffectWhen === state) if (plugin && plugin.editorStateEffect) { this.stateUnmountFn = plugin.editorStateEffect(payload.comment) diff --git a/ui/artalk/src/layer/layer-manager.ts b/ui/artalk/src/layer/layer-manager.ts index da47e1e4..73be931a 100644 --- a/ui/artalk/src/layer/layer-manager.ts +++ b/ui/artalk/src/layer/layer-manager.ts @@ -1,19 +1,11 @@ import { getScrollbarHelper } from './scrollbar-helper' import { LayerWrap } from './wrap' -import type { ContextApi } from '@/types' +import type { LayerManager as ILayerManager } from '@/types' -export class LayerManager { - private wrap: LayerWrap +export class LayerManager implements ILayerManager { + private wrap = new LayerWrap() - constructor(ctx: ContextApi) { - this.wrap = new LayerWrap() - document.body.appendChild(this.wrap.getWrap()) - - ctx.on('unmounted', () => { - this.wrap.getWrap().remove() - }) - - // 记录页面原始 CSS 属性 + constructor() { getScrollbarHelper().init() } @@ -24,4 +16,8 @@ export class LayerManager { create(name: string, el?: HTMLElement) { return this.wrap.createItem(name, el) } + + destroy() { + this.wrap.getWrap().remove() + } } diff --git a/ui/artalk/src/layer/layer.ts b/ui/artalk/src/layer/layer.ts index f60319c5..da64471b 100644 --- a/ui/artalk/src/layer/layer.ts +++ b/ui/artalk/src/layer/layer.ts @@ -1,11 +1,11 @@ -import type { Layer as LayerApi } from '@/types/layer' +import type { Layer as ILayer } from '@/types/layer' export interface LayerOptions { onShow: () => void onHide: () => void } -export class Layer implements LayerApi { +export class Layer implements ILayer { private allowMaskClose = true private onAfterHide?: () => void diff --git a/ui/artalk/src/layer/sidebar-layer.ts b/ui/artalk/src/layer/sidebar-layer.ts index 3b241af7..3ab71d9e 100644 --- a/ui/artalk/src/layer/sidebar-layer.ts +++ b/ui/artalk/src/layer/sidebar-layer.ts @@ -1,20 +1,39 @@ import SidebarHTML from './sidebar-layer.html?raw' -import type { Layer } from './layer' -import type { ContextApi, SidebarShowPayload } from '@/types' -import Component from '@/lib/component' +import type { + SidebarShowPayload, + ConfigManager, + UserManager, + LayerManager, + Layer, + SidebarLayer as ISidebarLayer, + CheckerManager, +} from '@/types' import * as Utils from '@/lib/utils' import * as Ui from '@/lib/ui' +import type { Api } from '@/api' -export default class SidebarLayer extends Component { +export interface SidebarLayerOptions { + onShow?: () => void + onHide?: () => void + + getCheckers: () => CheckerManager + getApi: () => Api + getConf: () => ConfigManager + getUser: () => UserManager + getLayers: () => LayerManager +} + +export class SidebarLayer implements ISidebarLayer { + private opts: SidebarLayerOptions + public $el: HTMLElement public layer?: Layer public $header: HTMLElement public $closeBtn: HTMLElement public $iframeWrap: HTMLElement public $iframe?: HTMLIFrameElement - constructor(ctx: ContextApi) { - super(ctx) - + constructor(opts: SidebarLayerOptions) { + this.opts = opts this.$el = Utils.createElement(SidebarHTML) this.$header = this.$el.querySelector('.atk-sidebar-header')! this.$closeBtn = this.$header.querySelector('.atk-sidebar-close')! @@ -23,11 +42,10 @@ export default class SidebarLayer extends Component { this.$closeBtn.onclick = () => { this.hide() } + } - // event - this.ctx.on('user-changed', () => { - this.refreshWhenShow = true - }) + public async onUserChanged() { + this.refreshWhenShow = true } /** Refresh iFrame when show */ @@ -72,11 +90,7 @@ export default class SidebarLayer extends Component { this.animTimer = undefined this.$el.style.transform = 'translate(0, 0)' - setTimeout(() => { - this.ctx.getData().updateNotifies([]) - }, 0) - - this.ctx.trigger('sidebar-show') + this.opts.onShow?.() }, 100) } @@ -89,15 +103,15 @@ export default class SidebarLayer extends Component { private async authCheck(opts: { onSuccess: () => void }) { const data = ( - await this.ctx.getApi().user.getUserStatus({ - ...this.ctx.getApi().getUserFields(), + await this.opts.getApi().user.getUserStatus({ + ...this.opts.getApi().getUserFields(), }) ).data if (data.is_admin && !data.is_login) { this.refreshWhenShow = true // show checker layer - this.ctx.checkAdmin({ + this.opts.getCheckers().checkAdmin({ onSuccess: () => { setTimeout(() => { opts.onSuccess() @@ -116,11 +130,8 @@ export default class SidebarLayer extends Component { private initLayer() { if (this.layer) return - this.layer = this.ctx.get('layerManager').create('sidebar', this.$el) + this.layer = this.opts.getLayers().create('sidebar', this.$el) this.layer.setOnAfterHide(() => { - // 防止评论框被吞 - this.ctx.editorResetState() - // interrupt animation this.animTimer && clearTimeout(this.animTimer) @@ -128,7 +139,7 @@ export default class SidebarLayer extends Component { this.$el.style.transform = '' // trigger event - this.ctx.trigger('sidebar-hide') + this.opts.onHide?.() }) } @@ -141,14 +152,14 @@ export default class SidebarLayer extends Component { const baseURL = import.meta.env.DEV ? 'http://localhost:23367/' : Utils.getURLBasedOnApi({ - base: this.ctx.conf.server, + base: this.opts.getConf().get().server, path: '/sidebar/', }) const query: any = { - pageKey: this.conf.pageKey, - site: this.conf.site || '', - user: JSON.stringify(this.ctx.get('user').getData()), + pageKey: this.opts.getConf().get().pageKey, + site: this.opts.getConf().get().site || '', + user: JSON.stringify(this.opts.getUser().getData()), time: +new Date(), } @@ -162,9 +173,9 @@ export default class SidebarLayer extends Component { } private getDarkMode() { - return this.conf.darkMode === 'auto' + return this.opts.getConf().get().darkMode === 'auto' ? window.matchMedia('(prefers-color-scheme: dark)').matches - : this.conf.darkMode + : this.opts.getConf().get().darkMode } private iframeLoad($iframe: HTMLIFrameElement, src: string) { diff --git a/ui/artalk/src/lib/component.ts b/ui/artalk/src/lib/component.ts deleted file mode 100644 index c1793d25..00000000 --- a/ui/artalk/src/lib/component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ContextApi } from '@/types' - -export default abstract class Component { - public $el!: HTMLElement - public get conf() { - return this.ctx.conf - } - - public constructor(public ctx: ContextApi) {} - - getEl() { - return this.$el - } -} diff --git a/ui/artalk/src/lib/event-manager.ts b/ui/artalk/src/lib/event-manager.ts index ac23e9fa..fa730392 100644 --- a/ui/artalk/src/lib/event-manager.ts +++ b/ui/artalk/src/lib/event-manager.ts @@ -1,37 +1,15 @@ -export type EventHandler = (payload: T) => void -export interface Event - extends EventOptions { - name: K - handler: EventHandler -} -export interface EventOptions { - once?: boolean -} - -export interface EventManagerFuncs { - on( - name: K, - handler: EventHandler, - opts?: EventOptions, - ): void - off(name: K, handler: EventHandler): void - trigger(name: K, payload?: PayloadMap[K]): void -} +import type { EventManager as IEventManager, Event, EventHandler, EventOptions } from '@/types' -export default class EventManager implements EventManagerFuncs { - private events: Event[] = [] +export class EventManager implements IEventManager { + private events: Event[] = [] /** * Add an event listener for a specific event name */ - public on( - name: K, - handler: EventHandler, - opts: EventOptions = {}, - ) { + public on(name: K, handler: EventHandler, opts: EventOptions = {}) { this.events.push({ name, - handler: handler as EventHandler, + handler: handler as EventHandler, ...opts, }) } @@ -39,7 +17,7 @@ export default class EventManager implements EventManagerFuncs(name: K, handler: EventHandler) { + public off(name: K, handler: EventHandler) { if (!handler) return // not allow remove all events with same name this.events = this.events.filter((evt) => !(evt.name === name && evt.handler === handler)) } @@ -47,7 +25,7 @@ export default class EventManager implements EventManagerFuncs(name: K, payload?: PayloadMap[K]) { + public trigger(name: K, payload?: T[K]) { this.events .slice(0) // make a copy, in case listeners are removed while iterating .filter((evt) => evt.name === name && typeof evt.handler === 'function') diff --git a/ui/artalk/src/lib/marked-renderer.ts b/ui/artalk/src/lib/marked-renderer.ts index e9c841e5..e9f7113c 100644 --- a/ui/artalk/src/lib/marked-renderer.ts +++ b/ui/artalk/src/lib/marked-renderer.ts @@ -1,9 +1,9 @@ import { marked, Tokens } from 'marked' import { renderCode } from './highlight' -import type { ArtalkConfig } from '@/types' +import type { Config } from '@/types' export interface RendererOptions { - imgLazyLoad: ArtalkConfig['imgLazyLoad'] + imgLazyLoad: Config['imgLazyLoad'] } export function getRenderer(options: RendererOptions) { diff --git a/ui/artalk/src/lib/marked.ts b/ui/artalk/src/lib/marked.ts index f09f1ab9..8dc36b83 100644 --- a/ui/artalk/src/lib/marked.ts +++ b/ui/artalk/src/lib/marked.ts @@ -4,7 +4,7 @@ import type { MarkedOptions } from 'marked' import { sanitize } from './sanitizer' import { renderCode } from './highlight' import { getRenderer } from './marked-renderer' -import type { ArtalkConfig } from '@/types' +import type { Config } from '@/types' type Replacer = (raw: string) => string @@ -27,8 +27,8 @@ export function setReplacers(arr: Replacer[]) { } export interface MarkedInitOptions { - markedOptions: ArtalkConfig['markedOptions'] - imgLazyLoad: ArtalkConfig['imgLazyLoad'] + markedOptions: Config['markedOptions'] + imgLazyLoad: Config['imgLazyLoad'] } /** 初始化 marked */ diff --git a/ui/artalk/src/lib/user.ts b/ui/artalk/src/lib/user.ts index 8d0de67a..4ff524f9 100644 --- a/ui/artalk/src/lib/user.ts +++ b/ui/artalk/src/lib/user.ts @@ -1,4 +1,4 @@ -import type { LocalUser } from '@/types' +import type { LocalUser, UserManager as IUserManager } from '@/types' const LOCAL_USER_KEY = 'ArtalkUser' @@ -6,7 +6,7 @@ interface UserOpts { onUserChanged?: (user: LocalUser) => void } -class User { +export class UserManager implements IUserManager { private data: LocalUser constructor(private opts: UserOpts) { @@ -55,4 +55,4 @@ class User { } } -export default User +export default UserManager diff --git a/ui/artalk/src/lib/watch-conf.ts b/ui/artalk/src/lib/watch-conf.ts index d28c47a5..f0a7b7bb 100644 --- a/ui/artalk/src/lib/watch-conf.ts +++ b/ui/artalk/src/lib/watch-conf.ts @@ -1,13 +1,19 @@ -import type { ArtalkConfig, ContextApi } from '@/types' +import type { Config, EventManager } from '@/types' -export function watchConf( - ctx: ContextApi, - keys: T, - effect: (conf: Pick) => void, -): void { +export function watchConf({ + keys, + effect, + getConf, + getEvents, +}: { + keys: T + effect: (conf: Pick) => void + getConf: () => Config + getEvents: () => EventManager +}): void { const deepEqual = (a: any, b: any) => JSON.stringify(a) === JSON.stringify(b) const val = () => { - const conf = ctx.getConf() + const conf = getConf() const res: any = {} keys.forEach((key) => { res[key] = conf[key] @@ -24,6 +30,6 @@ export function watchConf( effect(newVal) } } - ctx.on('mounted', handler) - ctx.on('updated', handler) + getEvents().on('mounted', handler) + getEvents().on('updated', handler) } diff --git a/ui/artalk/src/list/comment.ts b/ui/artalk/src/list/comment.ts index 753cc0f9..708adbd8 100644 --- a/ui/artalk/src/list/comment.ts +++ b/ui/artalk/src/list/comment.ts @@ -1,48 +1,54 @@ -import type { ContextApi, CommentData } from '@/types' +import type { ConfigManager, EventManager, CommentData, DataManager } from '@/types' import { CommentNode } from '@/comment' +import type { Api } from '@/api' interface CreateCommentNodeOptions { + getApi: () => Api + getEvents: () => EventManager + getData: () => DataManager + getConf: () => ConfigManager + replyComment: (c: CommentData, $el: HTMLElement) => void + editComment: (c: CommentData, $el: HTMLElement) => void forceFlatMode?: boolean } export function createCommentNode( - ctx: ContextApi, + opts: CreateCommentNodeOptions, comment: CommentData, replyComment?: CommentData, - opts?: CreateCommentNodeOptions, ): CommentNode { + const conf = opts.getConf().get() + const instance = new CommentNode(comment, { onAfterRender: () => { - ctx.trigger('comment-rendered', instance) + opts.getEvents().trigger('comment-rendered', instance) }, onDelete: (c: CommentNode) => { - ctx.getData().deleteComment(c.getID()) + opts.getData().deleteComment(c.getID()) }, replyTo: replyComment, // TODO simplify reference flatMode: - typeof opts?.forceFlatMode === 'boolean' - ? opts?.forceFlatMode - : (ctx.conf.flatMode as boolean), - gravatar: ctx.conf.gravatar, - nestMax: ctx.conf.nestMax, - heightLimit: ctx.conf.heightLimit, - avatarURLBuilder: ctx.conf.avatarURLBuilder, - scrollRelativeTo: ctx.conf.scrollRelativeTo, - vote: ctx.conf.vote, - voteDown: ctx.conf.voteDown, - uaBadge: ctx.conf.uaBadge, - dateFormatter: ctx.conf.dateFormatter, + typeof opts?.forceFlatMode === 'boolean' ? opts?.forceFlatMode : (conf.flatMode as boolean), + gravatar: conf.gravatar, + nestMax: conf.nestMax, + heightLimit: conf.heightLimit, + avatarURLBuilder: conf.avatarURLBuilder, + scrollRelativeTo: conf.scrollRelativeTo, + vote: conf.vote, + voteDown: conf.voteDown, + uaBadge: conf.uaBadge, + dateFormatter: conf.dateFormatter, // TODO: move to plugin folder and remove from core - getApi: () => ctx.getApi(), - replyComment: (c, $el) => ctx.replyComment(c, $el), - editComment: (c, $el) => ctx.editComment(c, $el), + getApi: opts.getApi, + replyComment: opts.replyComment, + editComment: opts.editComment, }) - // 渲染元素 + // Render comment instance.render() return instance diff --git a/ui/artalk/src/list/layout/flat.ts b/ui/artalk/src/list/layout/flat.ts index 1fa269c0..a0dc07e6 100644 --- a/ui/artalk/src/list/layout/flat.ts +++ b/ui/artalk/src/list/layout/flat.ts @@ -1,6 +1,5 @@ import type { LayoutStrategyCreator, LayoutOptions } from '.' import type { CommentData } from '@/types' -import * as Ui from '@/lib/ui' export const createFlatStrategy: LayoutStrategyCreator = (opts) => ({ import: (comments) => { diff --git a/ui/artalk/src/list/layout/nest.ts b/ui/artalk/src/list/layout/nest.ts index c08574dd..7e2aade8 100644 --- a/ui/artalk/src/list/layout/nest.ts +++ b/ui/artalk/src/list/layout/nest.ts @@ -1,6 +1,5 @@ import type { LayoutStrategyCreator } from '.' import type { CommentNode } from '@/comment' -import * as Ui from '@/lib/ui' import * as ListNest from '@/list/nest' export const createNestStrategy: LayoutStrategyCreator = (opts) => ({ diff --git a/ui/artalk/src/list/list.ts b/ui/artalk/src/list/list.ts index 6bcbbe95..bc172809 100644 --- a/ui/artalk/src/list/list.ts +++ b/ui/artalk/src/list/list.ts @@ -2,47 +2,69 @@ import ListHTML from './list.html?raw' import { ListLayout } from './layout' import { createCommentNode } from './comment' import { initListPaginatorFunc } from './page' -import type { ContextApi } from '@/types' -import Component from '@/lib/component' +import type { CommentData, EventManager, DataManager, ConfigManager, List as IList } from '@/types' import * as Utils from '@/lib/utils' import { CommentNode } from '@/comment' +import type { Api } from '@/api' -export default class List extends Component { - /** 列表评论集区域元素 */ - $commentsWrap!: HTMLElement - public getCommentsWrapEl() { +export interface ListOptions { + getApi: () => Api + getEvents: () => EventManager + getConf: () => ConfigManager + getData: () => DataManager + + replyComment: (c: CommentData, $el: HTMLElement) => void + editComment: (c: CommentData, $el: HTMLElement) => void + resetEditorState: () => void + onListGotoFirst?: () => void +} + +export class List implements IList { + private opts: ListOptions + private $el: HTMLElement + getEl() { + return this.$el + } + + private $commentsWrap: HTMLElement + getCommentsWrapEl() { return this.$commentsWrap } - protected commentNodes: CommentNode[] = [] + private commentNodes: CommentNode[] = [] getCommentNodes() { return this.commentNodes } - constructor(ctx: ContextApi) { - super(ctx) + constructor(opts: ListOptions) { + this.opts = opts // Init base element this.$el = Utils.createElement(ListHTML) this.$commentsWrap = this.$el.querySelector('.atk-list-comments-wrap')! // Init paginator - initListPaginatorFunc(ctx) + initListPaginatorFunc({ + getList: () => this, + ...opts, + }) // Bind events this.initCrudEvents() } - getListLayout({ forceFlatMode }: { forceFlatMode?: boolean } = {}) { + getLayout({ forceFlatMode }: { forceFlatMode?: boolean } = {}) { return new ListLayout({ $commentsWrap: this.$commentsWrap, - nestSortBy: this.ctx.conf.nestSort, - nestMax: this.ctx.conf.nestMax, + nestSortBy: this.opts.getConf().get().nestSort, + nestMax: this.opts.getConf().get().nestMax, flatMode: - typeof forceFlatMode === 'boolean' ? forceFlatMode : (this.ctx.conf.flatMode as boolean), + typeof forceFlatMode === 'boolean' + ? forceFlatMode + : (this.opts.getConf().get().flatMode as boolean), // flatMode must be boolean because it had been handled when Artalk.init createCommentNode: (d, r) => { - const node = createCommentNode(this.ctx, d, r, { forceFlatMode }) + const node = createCommentNode({ forceFlatMode, ...this.opts }, d, r) this.commentNodes.push(node) // store node instance return node }, @@ -51,12 +73,12 @@ export default class List extends Component { } private initCrudEvents() { - this.ctx.on('list-load', (comments) => { + this.opts.getEvents().on('list-load', (comments) => { // 导入数据 - this.getListLayout().import(comments) + this.getLayout().import(comments) }) - this.ctx.on('list-loaded', (comments) => { + this.opts.getEvents().on('list-loaded', (comments) => { if (comments.length === 0) { this.commentNodes = [] this.$commentsWrap.innerHTML = '' @@ -64,15 +86,15 @@ export default class List extends Component { }) // When comment insert - this.ctx.on('comment-inserted', (comment) => { + this.opts.getEvents().on('comment-inserted', (comment) => { const replyComment = comment.rid ? this.commentNodes.find((c) => c.getID() === comment.rid)?.getData() : undefined - this.getListLayout().insert(comment, replyComment) + this.getLayout().insert(comment, replyComment) }) // When comment delete - this.ctx.on('comment-deleted', (comment) => { + this.opts.getEvents().on('comment-deleted', (comment) => { const node = this.commentNodes.find((c) => c.getID() === comment.id) if (!node) { console.error(`comment node id=${comment.id} not found`) @@ -84,7 +106,7 @@ export default class List extends Component { }) // When comment update - this.ctx.on('comment-updated', (comment) => { + this.opts.getEvents().on('comment-updated', (comment) => { const node = this.commentNodes.find((c) => c.getID() === comment.id) node && node.setData(comment) }) diff --git a/ui/artalk/src/list/page.ts b/ui/artalk/src/list/page.ts index 803ba1dc..139136f2 100644 --- a/ui/artalk/src/list/page.ts +++ b/ui/artalk/src/list/page.ts @@ -1,19 +1,19 @@ +import type { ListOptions } from './list' import { Paginator } from './paginator' import ReadMorePaginator from './paginator/read-more' import UpDownPaginator from './paginator/up-down' import $t from '@/i18n' -import type { ArtalkConfig, ContextApi } from '@/types' +import type { Config, List, ListLastFetchData } from '@/types' -function createPaginatorByConf(conf: Pick): Paginator { +function createPaginatorByConf(conf: Pick): Paginator { if (conf.pagination.readMore) return new ReadMorePaginator() return new UpDownPaginator() } -function getPageDataByLastData(ctx: ContextApi): { +function getPageDataByLastData(last: ListLastFetchData | undefined): { offset: number total: number } { - const last = ctx.getData().getListLastFetch() const r = { offset: 0, total: 0 } if (!last) return r @@ -23,12 +23,17 @@ function getPageDataByLastData(ctx: ContextApi): { return r } -export const initListPaginatorFunc = (ctx: ContextApi) => { +export interface PaginatorInitOptions extends ListOptions { + getList: () => List +} + +export const initListPaginatorFunc = (opts: PaginatorInitOptions) => { let paginator: Paginator | null = null // Init paginator when conf loaded - ctx.watchConf(['pagination', 'locale'], (conf) => { - const list = ctx.get('list') + opts.getConf().watchConf(['pagination', 'locale'], (conf) => { + const list = opts.getList() + const data = opts.getData() if (paginator) paginator.dispose() // if had been init, dispose it @@ -36,47 +41,48 @@ export const initListPaginatorFunc = (ctx: ContextApi) => { paginator = createPaginatorByConf(conf) // create paginator dom - const { offset, total } = getPageDataByLastData(ctx) + const { offset, total } = getPageDataByLastData(data.getListLastFetch()) const $paginator = paginator.create({ - ctx, pageSize: conf.pagination.pageSize, total, readMoreAutoLoad: conf.pagination.autoLoad, + + ...opts, }) // mount paginator dom - list.$commentsWrap.after($paginator) + list.getCommentsWrapEl().after($paginator) // update paginator info paginator?.update(offset, total) }) // When list loaded - ctx.on('list-loaded', (comments) => { + opts.getEvents().on('list-loaded', (comments) => { // update paginator info - const { offset, total } = getPageDataByLastData(ctx) + const { offset, total } = getPageDataByLastData(opts.getData().getListLastFetch()) paginator?.update(offset, total) }) // When list fetch - ctx.on('list-fetch', (params) => { + opts.getEvents().on('list-fetch', (params) => { // if clear comments when fetch new page data - if (ctx.getData().getComments().length > 0 && paginator?.getIsClearComments(params)) { - ctx.getData().clearComments() + if (opts.getData().getComments().length > 0 && paginator?.getIsClearComments(params)) { + opts.getData().clearComments() } }) // When list error - ctx.on('list-failed', () => { + opts.getEvents().on('list-failed', () => { paginator?.showErr?.($t('loadFail')) }) // loading - ctx.on('list-fetch', (params) => { + opts.getEvents().on('list-fetch', (params) => { paginator?.setLoading(true) }) - ctx.on('list-fetched', ({ params }) => { + opts.getEvents().on('list-fetched', ({ params }) => { paginator?.setLoading(false) }) } diff --git a/ui/artalk/src/list/paginator/index.ts b/ui/artalk/src/list/paginator/index.ts index 27da01f5..f476eca0 100644 --- a/ui/artalk/src/list/paginator/index.ts +++ b/ui/artalk/src/list/paginator/index.ts @@ -1,9 +1,8 @@ +import type { ListOptions } from '../list' import UpDownPaginator from './up-down' import ReadMorePaginator from './read-more' -import type { ContextApi, ListFetchParams } from '@/types' -export interface IPgHolderOpt { - ctx: ContextApi +export interface IPgHolderOpt extends ListOptions { total: number pageSize: number diff --git a/ui/artalk/src/list/paginator/read-more.ts b/ui/artalk/src/list/paginator/read-more.ts index 1eb885e1..664a5f86 100644 --- a/ui/artalk/src/list/paginator/read-more.ts +++ b/ui/artalk/src/list/paginator/read-more.ts @@ -1,5 +1,4 @@ import type { Paginator, IPgHolderOpt } from '.' -import type { ListFetchParams } from '@/types' import ReadMoreBtn from '@/components/read-more-btn' import $t from '@/i18n' @@ -17,7 +16,7 @@ export default class ReadMorePaginator implements Paginator { this.instance = new ReadMoreBtn({ pageSize: opt.pageSize, onClick: async (o) => { - opt.ctx.fetch({ + opt.getData().fetchComments({ offset: o, }) }, @@ -27,10 +26,10 @@ export default class ReadMorePaginator implements Paginator { // 滚动到底部自动加载 if (opt.readMoreAutoLoad) { this.onReachedBottom = () => { - if (!this.instance.hasMore || this.opt.ctx.getData().getLoading()) return + if (!this.instance.hasMore || this.opt.getData().getLoading()) return this.instance.click() } - this.opt.ctx.on('list-reach-bottom', this.onReachedBottom) + this.opt.getEvents().on('list-reach-bottom', this.onReachedBottom) } return this.instance.$el @@ -61,7 +60,7 @@ export default class ReadMorePaginator implements Paginator { } dispose(): void { - this.onReachedBottom && this.opt.ctx.off('list-reach-bottom', this.onReachedBottom) + this.onReachedBottom && this.opt.getEvents().off('list-reach-bottom', this.onReachedBottom) this.instance.$el.remove() } } diff --git a/ui/artalk/src/list/paginator/up-down.ts b/ui/artalk/src/list/paginator/up-down.ts index 3ec10c50..f39c2c2b 100644 --- a/ui/artalk/src/list/paginator/up-down.ts +++ b/ui/artalk/src/list/paginator/up-down.ts @@ -11,12 +11,12 @@ export default class UpDownPaginator implements Paginator { this.instance = new PaginationComponent(opt.total, { pageSize: opt.pageSize, onChange: async (o) => { - opt.ctx.editorResetState() // 防止评论框被吞 + opt.resetEditorState() - opt.ctx.fetch({ + opt.getData().fetchComments({ offset: o, onSuccess: () => { - opt.ctx.listGotoFirst() + opt.onListGotoFirst?.() }, }) }, diff --git a/ui/artalk/src/load.ts b/ui/artalk/src/load.ts deleted file mode 100644 index 91e67153..00000000 --- a/ui/artalk/src/load.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { DefaultPlugins } from './plugins' -import type { ArtalkConfigPartial, ArtalkPlugin, ContextApi } from '@/types' -import { handleConfFormServer } from '@/config' -import { showErrorDialog } from '@/components/error-dialog' - -/** - * Global Plugins for all Artalk instances - */ -export const GlobalPlugins: Set = new Set([...DefaultPlugins]) - -/** - * Plugin options for plugin initialization - */ -export const PluginOptions: WeakMap = new WeakMap() - -export async function load(ctx: ContextApi) { - const loadedPlugins = new Set() - const loadPlugins = (plugins: Set) => { - plugins.forEach((plugin) => { - if (typeof plugin === 'function' && !loadedPlugins.has(plugin)) { - plugin(ctx, PluginOptions.get(plugin)) - loadedPlugins.add(plugin) - } - }) - } - - // Load local plugins - loadPlugins(GlobalPlugins) - - // Get conf from server - const { data } = await ctx - .getApi() - .conf.conf() - .catch((err) => { - onLoadErr(ctx, err) - throw err - }) - - // Initial config - let conf: ArtalkConfigPartial = { - apiVersion: data.version?.version, // version info - } - - // Reference conf from backend - if (ctx.conf.useBackendConf) { - if (!data.frontend_conf) - throw new Error( - 'The remote backend does not respond to the frontend conf, but `useBackendConf` conf is enabled', - ) - conf = { ...conf, ...handleConfFormServer(data.frontend_conf) } - } - - // Apply conf modifier - ctx.conf.remoteConfModifier && ctx.conf.remoteConfModifier(conf) - - // Dynamically load network plugins - conf.pluginURLs && - (await loadNetworkPlugins(conf.pluginURLs, ctx.conf.server) - .then((plugins) => { - loadPlugins(plugins) - }) - .catch((err) => { - console.error('Failed to load plugin', err) - })) - - // After all plugins are loaded - ctx.trigger('created') - - // Apply conf updating - ctx.updateConf(conf) - - // Trigger mounted event - ctx.trigger('mounted') - - // Load comment list - if (!ctx.conf.remoteConfModifier) { - // only auto fetch when no remoteConfModifier - ctx.fetch({ offset: 0 }) - } -} - -/** - * Dynamically load plugins from Network - */ -async function loadNetworkPlugins(scripts: string[], apiBase: string): Promise> { - const networkPlugins = new Set() - if (!scripts || !Array.isArray(scripts)) return networkPlugins - - const tasks: Promise[] = [] - - scripts.forEach((url) => { - // check url valid - if (!/^(http|https):\/\//.test(url)) - url = `${apiBase.replace(/\/$/, '')}/${url.replace(/^\//, '')}` - - tasks.push( - new Promise((resolve) => { - // check if loaded - if (document.querySelector(`script[src="${url}"]`)) { - resolve() - return - } - - // load script - const script = document.createElement('script') - script.src = url - document.head.appendChild(script) - script.onload = () => resolve() - script.onerror = (err) => { - console.error('[artalk] Failed to load plugin', err) - resolve() - } - }), - ) - }) - - await Promise.all(tasks) - - // Read ArtalkPlugins object from window - Object.values(window.ArtalkPlugins || {}).forEach((plugin) => { - if (typeof plugin === 'function') networkPlugins.add(plugin) - }) - - return networkPlugins -} - -export function onLoadErr(ctx: ContextApi, err: any) { - let sidebarOpenView = '' - - // if response err_no_site, modify the sidebar open view to create site - if (err.data?.err_no_site) { - const viewLoadParam = { - create_name: ctx.conf.site, - create_urls: `${window.location.protocol}//${window.location.host}`, - } - sidebarOpenView = `sites|${JSON.stringify(viewLoadParam)}` - } - - showErrorDialog({ - $err: ctx.get('list').$el, - errMsg: err.msg || String(err), - errData: err.data, - retryFn: () => load(ctx), - onOpenSidebar: ctx.get('user').getData().is_admin - ? () => - ctx.showSidebar({ - view: sidebarOpenView as any, - }) - : undefined, // only show open sidebar button when user is admin - }) -} diff --git a/ui/artalk/src/main.ts b/ui/artalk/src/main.ts index 4be9b61a..f34ff1de 100644 --- a/ui/artalk/src/main.ts +++ b/ui/artalk/src/main.ts @@ -1,16 +1,17 @@ import Artalk from './artalk' import type * as ArtalkType from './types' +import { Defaults } from './defaults' export type * from './types' -export { ArtalkType } +export { ArtalkType, Defaults } export default Artalk -// Expose the static methods from the Artalk class -// because direct export of static methods is not supported -// for adapting to different environments like CommonJS and browser IIFE -// for example, we can use `Artalk.init()` rather than `Artalk.default.init()` -// therefore, we need to manually expose the static methods in the Artalk class. +// Manually expose the static methods from the Artalk class. +// Directly exporting static methods from a class is not supported, +// and it's important to maintain consistency in the library's API. +// To ensure compatibility across different environments, such as CommonJS and browser IIFE, +// this approach allows us to use `Artalk.init()` instead of `Artalk.default.init()`. export const init = Artalk.init export const use = Artalk.use export const loadCountWidget = Artalk.loadCountWidget diff --git a/ui/artalk/src/mount.ts b/ui/artalk/src/mount.ts new file mode 100644 index 00000000..b3e2d6d6 --- /dev/null +++ b/ui/artalk/src/mount.ts @@ -0,0 +1,106 @@ +import { handleConfFormServer } from './config' +import { DefaultPlugins } from './plugins' +import { mergeDeep } from './lib/merge-deep' +import { MountError } from './plugins/mount-error' +import type { ConfigPartial, ArtalkPlugin, Context } from '@/types' + +/** + * Global Plugins for all Artalk instances + */ +export const GlobalPlugins: Set = new Set([...DefaultPlugins]) + +/** + * Plugin options for plugin initialization + */ +export const PluginOptions: WeakMap = new WeakMap() + +export async function mount(localConf: ConfigPartial, ctx: Context) { + const loaded = new Set() + const loadPlugins = (plugins: Set) => { + plugins.forEach((plugin) => { + if (typeof plugin !== 'function') return + if (loaded.has(plugin)) return + plugin(ctx, PluginOptions.get(plugin)) + loaded.add(plugin) + }) + } + + // Load local plugins + loadPlugins(GlobalPlugins) + + // Get conf from server + const { data } = await ctx + .getApi() + .conf.conf() + .catch((err) => { + MountError(ctx, { err, onRetry: () => mount(localConf, ctx) }) + throw err + }) + + // Merge remote and local config + let conf: ConfigPartial = { + ...localConf, + apiVersion: data.version?.version, // server version info + } + + const remoteConf = handleConfFormServer(data.frontend_conf || {}) + conf = mergeDeep(conf, remoteConf) + + // Apply local + remote conf + ctx.updateConf(conf) + + // Load remote plugins + conf.pluginURLs && + (await loadNetworkPlugins(conf.pluginURLs, ctx.getConf().server) + .then((plugins) => { + loadPlugins(plugins) + }) + .catch((err) => { + console.error('Failed to load plugin', err) + })) +} + +/** + * Dynamically load plugins from Network + */ +async function loadNetworkPlugins(scripts: string[], apiBase: string): Promise> { + const networkPlugins = new Set() + if (!scripts || !Array.isArray(scripts)) return networkPlugins + + const tasks: Promise[] = [] + + scripts.forEach((url) => { + // check url valid + if (!/^(http|https):\/\//.test(url)) + url = `${apiBase.replace(/\/$/, '')}/${url.replace(/^\//, '')}` + + tasks.push( + new Promise((resolve) => { + // check if loaded + if (document.querySelector(`script[src="${url}"]`)) { + resolve() + return + } + + // load script + const script = document.createElement('script') + script.src = url + document.head.appendChild(script) + script.onload = () => resolve() + script.onerror = (err) => { + console.error('[artalk] Failed to load plugin', err) + resolve() + } + }), + ) + }) + + await Promise.all(tasks) + + // Read ArtalkPlugins object from window + Object.values(window.ArtalkPlugins || {}).forEach((plugin) => { + if (typeof plugin === 'function') networkPlugins.add(plugin) + }) + + return networkPlugins +} diff --git a/ui/artalk/src/plugins/admin-only-elem.ts b/ui/artalk/src/plugins/admin-only-elem.ts index 7f7b3c1b..af6c4469 100644 --- a/ui/artalk/src/plugins/admin-only-elem.ts +++ b/ui/artalk/src/plugins/admin-only-elem.ts @@ -1,11 +1,13 @@ import type { ArtalkPlugin } from '@/types' export const AdminOnlyElem: ArtalkPlugin = (ctx) => { + const user = ctx.inject('user') + const scanApply = () => { applyAdminOnlyEls( - ctx.get('user').getData().is_admin, + user.getData().is_admin, getAdminOnlyEls({ - $root: ctx.$root, + $root: ctx.getEl(), }), ) } diff --git a/ui/artalk/src/plugins/dark-mode.ts b/ui/artalk/src/plugins/dark-mode.ts index 7f26c7fb..6d9fc669 100644 --- a/ui/artalk/src/plugins/dark-mode.ts +++ b/ui/artalk/src/plugins/dark-mode.ts @@ -14,12 +14,15 @@ function updateClassnames($els: HTMLElement[], darkMode: boolean) { } export const DarkMode: ArtalkPlugin = (ctx) => { + const conf = ctx.inject('config') + const layers = ctx.inject('layers') + // the handler bind to Artalk instance, don't forget to remove it when Artalk instance destroyed let darkModeAutoHandler: ((evt: MediaQueryListEvent) => void) | undefined const sync = (darkMode: boolean | 'auto') => { // the elements that classnames need to be updated when darkMode changed - const $els = [ctx.$root, ctx.get('layerManager').getEl()] + const $els = [ctx.getEl(), layers.getEl()] // init darkModeMedia if not exists, and only create once if (!darkModeMedia) { @@ -49,7 +52,7 @@ export const DarkMode: ArtalkPlugin = (ctx) => { } ctx.watchConf(['darkMode'], (conf) => sync(conf.darkMode)) - ctx.on('created', () => sync(ctx.conf.darkMode)) + ctx.on('created', () => sync(conf.get().darkMode)) ctx.on('unmounted', () => { // if handler exists, don't forget to remove it, or it will cause memory leak darkModeAutoHandler && darkModeMedia?.removeEventListener('change', darkModeAutoHandler) diff --git a/ui/artalk/src/plugins/editor-kit.ts b/ui/artalk/src/plugins/editor-kit.ts index bb8419b5..66c31c95 100644 --- a/ui/artalk/src/plugins/editor-kit.ts +++ b/ui/artalk/src/plugins/editor-kit.ts @@ -1,8 +1,17 @@ import { getEnabledPlugs } from './editor' -import EditorPlug from './editor/_plug' +import EditorPlugin from './editor/_plug' import PlugKit from './editor/_kit' -import EventManager from '@/lib/event-manager' -import type { EditorApi, ArtalkPlugin } from '@/types' +import { EventManager } from '@/lib/event-manager' +import type { + Editor, + ArtalkPlugin, + DataManager, + ConfigManager, + UserManager, + EditorPluginManager as IEditorPluginManager, + CheckerManager, +} from '@/types' +import type { Api } from '@/api' export interface EditorEventPayloadMap { mounted: undefined @@ -10,44 +19,58 @@ export interface EditorEventPayloadMap { 'header-input': { field: string; $input: HTMLInputElement } 'header-change': { field: string; $input: HTMLInputElement } 'content-updated': string - 'panel-show': EditorPlug - 'panel-hide': EditorPlug + 'panel-show': EditorPlugin + 'panel-hide': EditorPlugin 'panel-close': undefined 'editor-close': undefined 'editor-open': undefined + 'editor-submit': undefined + 'editor-submitted': undefined } export const EditorKit: ArtalkPlugin = (ctx) => { - const editor = ctx.get('editor') + ctx.provide( + 'editorPlugs', + (editor, config, user, data, checkers, api) => { + const pluginManager = new PluginManager({ + getArtalkRootEl: () => ctx.getEl(), + getEditor: () => editor, + getConf: () => config, + getUser: () => user, + getApi: () => api, + getData: () => data, + getCheckers: () => checkers, + onSubmitted: () => ctx.trigger('editor-submitted'), + }) + editor.setPlugins(pluginManager) + return pluginManager + }, + ['editor', 'config', 'user', 'data', 'checkers', 'api'] as const, + ) +} - const editorPlugs = new PlugManager(editor) - ctx.inject('editorPlugs', editorPlugs) +export interface PluginManagerOptions { + getArtalkRootEl: () => HTMLElement + getEditor: () => Editor + getConf: () => ConfigManager + getUser: () => UserManager + getApi: () => Api + getData: () => DataManager + getCheckers: () => CheckerManager + onSubmitted: () => void } -export class PlugManager { - private plugs: EditorPlug[] = [] - private openedPlug: EditorPlug | null = null +export class PluginManager implements IEditorPluginManager { + private plugins: EditorPlugin[] = [] + private openedPlug: EditorPlugin | null = null private events = new EventManager() - getPlugs() { - return this.plugs - } - getEvents() { - return this.events - } - - private clear() { - this.plugs = [] - this.events = new EventManager() - if (this.openedPlug) this.closePlugPanel() - } - - constructor(public editor: EditorApi) { + constructor(public opts: PluginManagerOptions) { let confLoaded = false // config not loaded at first time - this.editor.ctx.watchConf( - ['imgUpload', 'emoticons', 'preview', 'editorTravel', 'locale'], - (conf) => { + this.opts + .getConf() + .watchConf(['imgUpload', 'emoticons', 'preview', 'editorTravel', 'locale'], (conf) => { // trigger unmount event will call all plugs' unmount function // (this will only be called while conf reloaded, not be called at first time) confLoaded && this.getEvents().trigger('unmounted') @@ -59,7 +82,7 @@ export class PlugManager { getEnabledPlugs(conf).forEach((Plug) => { // create the plug instance const kit = new PlugKit(this) - this.plugs.push(new Plug(kit)) + this.plugins.push(new Plug(kit)) }) // trigger event for plug initialization @@ -68,33 +91,56 @@ export class PlugManager { // refresh the plug UI this.loadPluginUI() - }, - ) + }) + + this.events.on('panel-close', () => this.closePluginPanel()) + this.events.on('editor-submitted', () => opts.onSubmitted()) + } + + getPlugins() { + return this.plugins + } + + getEvents() { + return this.events + } - this.events.on('panel-close', () => this.closePlugPanel()) + getEditor() { + return this.opts.getEditor() + } + + getOptions() { + return this.opts + } + + private clear() { + this.plugins = [] + this.events = new EventManager() + if (this.openedPlug) this.closePluginPanel() } private loadPluginUI() { // handle ui, clear and reset the plug btns and plug panels - this.editor.getUI().$plugPanelWrap.innerHTML = '' - this.editor.getUI().$plugPanelWrap.style.display = 'none' - this.editor.getUI().$plugBtnWrap.innerHTML = '' + this.getEditor().getUI().$plugPanelWrap.innerHTML = '' + this.getEditor().getUI().$plugPanelWrap.style.display = 'none' + this.getEditor().getUI().$plugBtnWrap.innerHTML = '' // load the plug UI - this.plugs.forEach((plug) => this.loadPluginItem(plug)) + this.plugins.forEach((plug) => this.loadPluginItem(plug)) } /** Load the plug btn and plug panel on editor ui */ - private loadPluginItem(plug: EditorPlug) { + private loadPluginItem(plug: EditorPlugin) { const $btn = plug.$btn if (!$btn) return - this.editor.getUI().$plugBtnWrap.appendChild($btn) + this.getEditor().getUI().$plugBtnWrap.appendChild($btn) // bind the event when click plug btn !$btn.onclick && ($btn.onclick = () => { // removing the active class from all the buttons - this.editor + this.opts + .getEditor() .getUI() .$plugBtnWrap.querySelectorAll('.active') .forEach((item) => item.classList.remove('active')) @@ -102,13 +148,13 @@ export class PlugManager { // if the plug is not the same as the openedPlug, if (plug !== this.openedPlug) { // then open the plug current clicked plug panel - this.openPlugPanel(plug) + this.openPluginPanel(plug) // add active class for current plug panel $btn.classList.add('active') } else { // then close the plug - this.closePlugPanel() + this.closePluginPanel() } }) @@ -116,17 +162,17 @@ export class PlugManager { const $panel = plug.$panel if ($panel) { $panel.style.display = 'none' - this.editor.getUI().$plugPanelWrap.appendChild($panel) + this.getEditor().getUI().$plugPanelWrap.appendChild($panel) } } - get(plug: T) { - return this.plugs.find((p) => p instanceof plug) as InstanceType | undefined + get(plug: T) { + return this.plugins.find((p) => p instanceof plug) as InstanceType | undefined } /** Open the editor plug panel */ - openPlugPanel(plug: EditorPlug) { - this.plugs.forEach((aPlug) => { + openPluginPanel(plug: EditorPlugin) { + this.plugins.forEach((aPlug) => { const plugPanel = aPlug.$panel if (!plugPanel) return @@ -139,15 +185,15 @@ export class PlugManager { } }) - this.editor.getUI().$plugPanelWrap.style.display = '' + this.getEditor().getUI().$plugPanelWrap.style.display = '' this.openedPlug = plug } - /** Close the editor plug panel */ - closePlugPanel() { + /** Close the editor plugin panel */ + closePluginPanel() { if (!this.openedPlug) return - this.editor.getUI().$plugPanelWrap.style.display = 'none' + this.getEditor().getUI().$plugPanelWrap.style.display = 'none' this.events.trigger('panel-hide', this.openedPlug) this.openedPlug = null } @@ -155,7 +201,7 @@ export class PlugManager { /** Get the content which is transformed by plugs */ getTransformedContent(rawContent: string) { let result = rawContent - this.plugs.forEach((aPlug) => { + this.plugins.forEach((aPlug) => { if (!aPlug.contentTransformer) return result = aPlug.contentTransformer(result) }) diff --git a/ui/artalk/src/plugins/editor/_kit.ts b/ui/artalk/src/plugins/editor/_kit.ts index e2f01718..f22f42f1 100644 --- a/ui/artalk/src/plugins/editor/_kit.ts +++ b/ui/artalk/src/plugins/editor/_kit.ts @@ -1,49 +1,50 @@ -import type { PlugManager } from '../editor-kit' -import type EditorPlug from './_plug' +import type { PluginManager } from '../editor-kit' +import type EditorPlugin from './_plug' /** * PlugKit provides a set of methods to help you develop editor plug */ export default class PlugKit { - constructor(private plugs: PlugManager) {} + constructor(private plugins: PluginManager) {} /** Use the editor */ useEditor() { - return this.plugs.editor - } - - /** - * Use the context of global - * - * @deprecated The calls to this function should be reduced as much as possible - */ - useGlobalCtx() { - return this.plugs.editor.ctx + return this.plugins.getEditor() } /** Use the config of Artalk */ useConf() { - return this.plugs.editor.ctx.conf + return this.plugins.getOptions().getConf().get() } /** Use the http api client */ useApi() { - return this.plugs.editor.ctx.getApi() + return this.plugins.getOptions().getApi() + } + + /** Use the data manager */ + useData() { + return this.plugins.getOptions().getData() } /** Use the user manager */ useUser() { - return this.plugs.editor.ctx.get('user') + return this.plugins.getOptions().getUser() + } + + /** Use the checkers */ + useCheckers() { + return this.plugins.getOptions().getCheckers() } /** Use the ui of editor */ useUI() { - return this.plugs.editor.getUI() + return this.plugins.getEditor().getUI() } /** Use the events in editor scope */ useEvents() { - return this.plugs.getEvents() + return this.plugins.getEvents() } /** Listen the event when plug is mounted */ @@ -57,7 +58,12 @@ export default class PlugKit { } /** Use the deps of other plug */ - useDeps(plug: T) { - return this.plugs.get(plug) + useDeps(plug: T) { + return this.plugins.get(plug) + } + + /** Use the root element of artalk */ + useArtalkRootEl() { + return this.plugins.getOptions().getArtalkRootEl() } } diff --git a/ui/artalk/src/plugins/editor/_plug.ts b/ui/artalk/src/plugins/editor/_plug.ts index b2768031..a2c49c86 100644 --- a/ui/artalk/src/plugins/editor/_plug.ts +++ b/ui/artalk/src/plugins/editor/_plug.ts @@ -5,7 +5,7 @@ import * as Utils from '@/lib/utils' /** * Editor 插件 */ -class EditorPlug { +export class EditorPlugin { $btn?: HTMLElement $panel?: HTMLElement contentTransformer?(rawContent: string): string @@ -52,4 +52,4 @@ class EditorPlug { } } -export default EditorPlug +export default EditorPlugin diff --git a/ui/artalk/src/plugins/editor/_sample.ts b/ui/artalk/src/plugins/editor/_sample.ts index 0f6683e3..5229ea0e 100644 --- a/ui/artalk/src/plugins/editor/_sample.ts +++ b/ui/artalk/src/plugins/editor/_sample.ts @@ -1,7 +1,7 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' -export default class SamplePlug extends EditorPlug { +export default class SamplePlug extends EditorPlugin { constructor(kit: PlugKit) { super(kit) } diff --git a/ui/artalk/src/plugins/editor/closable.ts b/ui/artalk/src/plugins/editor/closable.ts index 58ac8959..cdb96b1a 100644 --- a/ui/artalk/src/plugins/editor/closable.ts +++ b/ui/artalk/src/plugins/editor/closable.ts @@ -1,9 +1,9 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import * as Utils from '@/lib/utils' import $t from '@/i18n' -export default class Closable extends EditorPlug { +export default class Closable extends EditorPlugin { constructor(kit: PlugKit) { super(kit) diff --git a/ui/artalk/src/plugins/editor/emoticons.ts b/ui/artalk/src/plugins/editor/emoticons.ts index 942cf5e3..92c070aa 100644 --- a/ui/artalk/src/plugins/editor/emoticons.ts +++ b/ui/artalk/src/plugins/editor/emoticons.ts @@ -1,6 +1,6 @@ import './emoticons.scss' -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import type { EmoticonListData, EmoticonGrpData } from '@/types' import * as Utils from '@/lib/utils' @@ -14,7 +14,7 @@ type OwOFormatType = { } } -export default class Emoticons extends EditorPlug { +export default class Emoticons extends EditorPlugin { private emoticons: EmoticonListData = [] private loadingTask: Promise | null = null diff --git a/ui/artalk/src/plugins/editor/header-event.ts b/ui/artalk/src/plugins/editor/header-event.ts index 8a512538..ab93dac5 100644 --- a/ui/artalk/src/plugins/editor/header-event.ts +++ b/ui/artalk/src/plugins/editor/header-event.ts @@ -1,7 +1,7 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' -export default class HeaderEvent extends EditorPlug { +export default class HeaderEvent extends EditorPlugin { private get $inputs() { return this.kit.useEditor().getHeaderInputEls() } diff --git a/ui/artalk/src/plugins/editor/header-link.ts b/ui/artalk/src/plugins/editor/header-link.ts index 7cfbb66c..25e1f2b2 100644 --- a/ui/artalk/src/plugins/editor/header-link.ts +++ b/ui/artalk/src/plugins/editor/header-link.ts @@ -1,7 +1,7 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' -export default class HeaderLink extends EditorPlug { +export default class HeaderLink extends EditorPlugin { constructor(kit: PlugKit) { super(kit) diff --git a/ui/artalk/src/plugins/editor/header-user.ts b/ui/artalk/src/plugins/editor/header-user.ts index 1eb5113c..79d33d19 100644 --- a/ui/artalk/src/plugins/editor/header-user.ts +++ b/ui/artalk/src/plugins/editor/header-user.ts @@ -1,9 +1,9 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import $t from '@/i18n' import type { UserInfoApiResponseData } from '@/types' -export default class HeaderUser extends EditorPlug { +export default class HeaderUser extends EditorPlugin { constructor(kit: PlugKit) { super(kit) @@ -87,12 +87,12 @@ export default class HeaderUser extends EditorPlug { if (!data.is_login) this.kit.useUser().logout() // Update unread notifies - this.kit.useGlobalCtx().getData().updateNotifies(data.notifies) + this.kit.useData().updateNotifies(data.notifies) // If user is admin and not login, if (this.kit.useUser().checkHasBasicUserInfo() && !data.is_login && data.user?.is_admin) { // then show login window - this.kit.useGlobalCtx().checkAdmin({ + this.kit.useCheckers().checkAdmin({ onSuccess: () => {}, }) } diff --git a/ui/artalk/src/plugins/editor/index.ts b/ui/artalk/src/plugins/editor/index.ts index 23baf841..c882c392 100644 --- a/ui/artalk/src/plugins/editor/index.ts +++ b/ui/artalk/src/plugins/editor/index.ts @@ -1,4 +1,4 @@ -import type EditorPlug from './_plug' +import type EditorPlugin from './_plug' import LocalStorage from './local-storage' import Textarea from './textarea' import SubmitBtn from './submit-btn' @@ -13,10 +13,10 @@ import Mover from './mover' import Emoticons from './emoticons' import Upload from './upload' import Preview from './preview' -import type { ArtalkConfig } from '@/types' +import type { Config } from '@/types' /** The default enabled plugs */ -const EDITOR_PLUGS: (typeof EditorPlug)[] = [ +const EDITOR_PLUGS: (typeof EditorPlugin)[] = [ // Core LocalStorage, HeaderEvent, @@ -40,11 +40,11 @@ const EDITOR_PLUGS: (typeof EditorPlug)[] = [ * Get the enabled plugs by config */ export function getEnabledPlugs( - conf: Pick, -): (typeof EditorPlug)[] { + conf: Pick, +): (typeof EditorPlugin)[] { // The reference map of config and plugs // (for check if the plug is enabled) - const confRefs = new Map() + const confRefs = new Map() confRefs.set(Upload, conf.imgUpload) confRefs.set(Emoticons, conf.emoticons) confRefs.set(Preview, conf.preview) diff --git a/ui/artalk/src/plugins/editor/local-storage.ts b/ui/artalk/src/plugins/editor/local-storage.ts index d76132cf..7087008a 100644 --- a/ui/artalk/src/plugins/editor/local-storage.ts +++ b/ui/artalk/src/plugins/editor/local-storage.ts @@ -1,10 +1,10 @@ import type PlugKit from './_kit' -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import $t from '@/i18n' const LocalStorageKey = 'ArtalkContent' -export default class LocalStorage extends EditorPlug { +export default class LocalStorage extends EditorPlugin { constructor(kit: PlugKit) { super(kit) diff --git a/ui/artalk/src/plugins/editor/mover.ts b/ui/artalk/src/plugins/editor/mover.ts index ca3cb1d2..87502ab8 100644 --- a/ui/artalk/src/plugins/editor/mover.ts +++ b/ui/artalk/src/plugins/editor/mover.ts @@ -1,7 +1,7 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import * as Utils from '@/lib/utils' -export default class Mover extends EditorPlug { +export default class Mover extends EditorPlugin { private isMoved = false move(afterEl: HTMLElement) { @@ -24,8 +24,8 @@ export default class Mover extends EditorPlug { if (!this.isMoved) return this.isMoved = false this.kit - .useGlobalCtx() - .$root.querySelector('.atk-editor-travel-placeholder') + .useArtalkRootEl() + .querySelector('.atk-editor-travel-placeholder') ?.replaceWith(this.kit.useUI().$el) this.kit.useUI().$el.classList.remove('editor-traveling') } diff --git a/ui/artalk/src/plugins/editor/preview.ts b/ui/artalk/src/plugins/editor/preview.ts index 6efc4d15..82eeb336 100644 --- a/ui/artalk/src/plugins/editor/preview.ts +++ b/ui/artalk/src/plugins/editor/preview.ts @@ -1,11 +1,10 @@ import './preview.scss' -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' -import * as marked from '@/lib/marked' import $t from '@/i18n' -export default class Preview extends EditorPlug { +export default class Preview extends EditorPlugin { private isPlugPanelShow = false constructor(kit: PlugKit) { diff --git a/ui/artalk/src/plugins/editor/state-edit.ts b/ui/artalk/src/plugins/editor/state-edit.ts index 834d69d7..72267434 100644 --- a/ui/artalk/src/plugins/editor/state-edit.ts +++ b/ui/artalk/src/plugins/editor/state-edit.ts @@ -1,11 +1,11 @@ import type PlugKit from './_kit' -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import Submit from './submit' import type { CommentData } from '@/types' import $t from '@/i18n' import * as Utils from '@/lib/utils' -export default class StateEdit extends EditorPlug { +export default class StateEdit extends EditorPlugin { private comment?: CommentData constructor(kit: PlugKit) { @@ -42,7 +42,7 @@ export default class StateEdit extends EditorPlug { return nComment.data }, post: (nComment: CommentData) => { - this.kit.useGlobalCtx().getData().updateComment(nComment) + this.kit.useData().updateComment(nComment) }, }) }) diff --git a/ui/artalk/src/plugins/editor/state-reply.ts b/ui/artalk/src/plugins/editor/state-reply.ts index 3c3c3f2c..9b202866 100644 --- a/ui/artalk/src/plugins/editor/state-reply.ts +++ b/ui/artalk/src/plugins/editor/state-reply.ts @@ -1,13 +1,12 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import Submit from './submit' import SubmitAddPreset from './submit-add' import type { CommentData } from '@/types' import * as Utils from '@/lib/utils' -import * as Ui from '@/lib/ui' import $t from '@/i18n' -export default class StateReply extends EditorPlug { +export default class StateReply extends EditorPlugin { private comment?: CommentData constructor(kit: PlugKit) { diff --git a/ui/artalk/src/plugins/editor/submit-add.ts b/ui/artalk/src/plugins/editor/submit-add.ts index 62ca0d62..58d19325 100644 --- a/ui/artalk/src/plugins/editor/submit-add.ts +++ b/ui/artalk/src/plugins/editor/submit-add.ts @@ -33,6 +33,6 @@ export default class SubmitAddPreset { postSubmitAdd(commentNew: CommentData) { // insert the new comment to list - this.kit.useGlobalCtx().getData().insertComment(commentNew) + this.kit.useData().insertComment(commentNew) } } diff --git a/ui/artalk/src/plugins/editor/submit-btn.ts b/ui/artalk/src/plugins/editor/submit-btn.ts index 139e5f5e..06ff2e1b 100644 --- a/ui/artalk/src/plugins/editor/submit-btn.ts +++ b/ui/artalk/src/plugins/editor/submit-btn.ts @@ -1,8 +1,8 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import $t from '@/i18n' -export default class SubmitBtn extends EditorPlug { +export default class SubmitBtn extends EditorPlugin { constructor(kit: PlugKit) { super(kit) diff --git a/ui/artalk/src/plugins/editor/submit.ts b/ui/artalk/src/plugins/editor/submit.ts index 5a9ee966..e4920818 100644 --- a/ui/artalk/src/plugins/editor/submit.ts +++ b/ui/artalk/src/plugins/editor/submit.ts @@ -1,4 +1,4 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import SubmitAddPreset from './submit-add' import $t from '@/i18n' @@ -12,7 +12,7 @@ interface CustomSubmit { post?: (nComment: CommentData) => void } -export default class Submit extends EditorPlug { +export default class Submit extends EditorPlugin { private customs: CustomSubmit[] = [] private defaultPreset: SubmitAddPreset @@ -25,10 +25,10 @@ export default class Submit extends EditorPlug { this.kit.useMounted(() => { // invoke `do()` when event `editor-submit` is triggered - this.kit.useGlobalCtx().on('editor-submit', onEditorSubmit) + this.kit.useEvents().on('editor-submit', onEditorSubmit) }) this.kit.useUnmounted(() => { - this.kit.useGlobalCtx().off('editor-submit', onEditorSubmit) + this.kit.useEvents().off('editor-submit', onEditorSubmit) }) } @@ -69,6 +69,6 @@ export default class Submit extends EditorPlug { } this.kit.useEditor().reset() // 复原编辑器 - this.kit.useGlobalCtx().trigger('editor-submitted') + this.kit.useEvents().trigger('editor-submitted') } } diff --git a/ui/artalk/src/plugins/editor/textarea.ts b/ui/artalk/src/plugins/editor/textarea.ts index cdaf1f4e..6deaf895 100644 --- a/ui/artalk/src/plugins/editor/textarea.ts +++ b/ui/artalk/src/plugins/editor/textarea.ts @@ -1,8 +1,8 @@ -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import type PlugKit from './_kit' import $t from '@/i18n' -export default class Textarea extends EditorPlug { +export default class Textarea extends EditorPlugin { constructor(kit: PlugKit) { super(kit) diff --git a/ui/artalk/src/plugins/editor/upload.ts b/ui/artalk/src/plugins/editor/upload.ts index 93225e27..62c00d12 100644 --- a/ui/artalk/src/plugins/editor/upload.ts +++ b/ui/artalk/src/plugins/editor/upload.ts @@ -1,12 +1,12 @@ import type PlugKit from './_kit' -import EditorPlug from './_plug' +import EditorPlugin from './_plug' import * as Utils from '@/lib/utils' import $t from '@/i18n' /** 允许的图片格式 */ const AllowImgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'] -export default class Upload extends EditorPlug { +export default class Upload extends EditorPlugin { private $imgUploadInput?: HTMLInputElement constructor(kit: PlugKit) { diff --git a/ui/artalk/src/plugins/index.ts b/ui/artalk/src/plugins/index.ts index b930c67c..cb310947 100644 --- a/ui/artalk/src/plugins/index.ts +++ b/ui/artalk/src/plugins/index.ts @@ -7,9 +7,11 @@ import { VersionCheck } from './version-check' import { AdminOnlyElem } from './admin-only-elem' import { DarkMode } from './dark-mode' import { PageVoteWidget } from './page-vote' +import { Services } from '@/services' import type { ArtalkPlugin } from '@/types' export const DefaultPlugins: ArtalkPlugin[] = [ + ...Services, Markdown, EditorKit, AdminOnlyElem, diff --git a/ui/artalk/src/plugins/list/copyright.ts b/ui/artalk/src/plugins/list/copyright.ts index a98af869..350276a7 100644 --- a/ui/artalk/src/plugins/list/copyright.ts +++ b/ui/artalk/src/plugins/list/copyright.ts @@ -2,10 +2,10 @@ import type { ArtalkPlugin } from '@/types' import { version as ARTALK_VERSION } from '~/package.json' export const Copyright: ArtalkPlugin = (ctx) => { - ctx.on('mounted', () => { - const list = ctx.get('list') + const list = ctx.inject('list') - const $copyright = list.$el.querySelector('.atk-copyright') + ctx.on('mounted', () => { + const $copyright = list.getEl().querySelector('.atk-copyright') if (!$copyright) return $copyright.innerHTML = diff --git a/ui/artalk/src/plugins/list/count.ts b/ui/artalk/src/plugins/list/count.ts index cf7ed045..3699e60c 100644 --- a/ui/artalk/src/plugins/list/count.ts +++ b/ui/artalk/src/plugins/list/count.ts @@ -3,10 +3,10 @@ import * as Utils from '@/lib/utils' import $t from '@/i18n' export const Count: ArtalkPlugin = (ctx) => { - const refreshCountNumEl = () => { - const list = ctx.get('list') + const list = ctx.inject('list') - const $count = list.$el.querySelector('.atk-comment-count .atk-text') + const refreshCountNumEl = () => { + const $count = list.getEl().querySelector('.atk-comment-count .atk-text') if (!$count) return const text = Utils.htmlEncode( diff --git a/ui/artalk/src/plugins/list/dropdown.ts b/ui/artalk/src/plugins/list/dropdown.ts index 2daf09b8..82ba7fe8 100644 --- a/ui/artalk/src/plugins/list/dropdown.ts +++ b/ui/artalk/src/plugins/list/dropdown.ts @@ -3,8 +3,13 @@ import * as Utils from '@/lib/utils' import $t from '@/i18n' export const Dropdown: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') + const conf = ctx.inject('config') + const reloadUseParamsEditor = (func: (p: any) => void) => { - ctx.conf.listFetchParamsModifier = func + conf.update({ + listFetchParamsModifier: func, + }) ctx.reload() } @@ -49,9 +54,7 @@ export const Dropdown: ArtalkPlugin = (ctx) => { } ctx.watchConf(['listSort', 'locale'], (conf) => { - const list = ctx.get('list') - - const $count = list.$el.querySelector('.atk-comment-count') + const $count = list.getEl().querySelector('.atk-comment-count') if (!$count) return // 评论列表排序 Dropdown 下拉选择层 diff --git a/ui/artalk/src/plugins/list/error-dialog.ts b/ui/artalk/src/plugins/list/error-dialog.ts index 450ecbfd..7e0fc224 100644 --- a/ui/artalk/src/plugins/list/error-dialog.ts +++ b/ui/artalk/src/plugins/list/error-dialog.ts @@ -1,18 +1,18 @@ import * as Ui from '../../lib/ui' -import type { ArtalkPlugin, ContextApi } from '@/types' +import type { ArtalkPlugin } from '@/types' import { showErrorDialog } from '@/components/error-dialog' export const ErrorDialog: ArtalkPlugin = (ctx) => { - ctx.on('list-fetch', () => { - const list = ctx.get('list') + const list = ctx.inject('list') + ctx.on('list-fetch', () => { // clear the original error when a new fetch is triggered - Ui.setError(list.$el, null) + Ui.setError(list.getEl(), null) }) ctx.on('list-failed', (err) => { showErrorDialog({ - $err: ctx.get('list').$el, + $err: list.getEl(), errMsg: err.msg, errData: err.data, retryFn: () => ctx.fetch({ offset: 0 }), diff --git a/ui/artalk/src/plugins/list/fetch.ts b/ui/artalk/src/plugins/list/fetch.ts index f81701c2..7c279e42 100644 --- a/ui/artalk/src/plugins/list/fetch.ts +++ b/ui/artalk/src/plugins/list/fetch.ts @@ -1,6 +1,8 @@ import type { ListFetchParams, ArtalkPlugin } from '@/types' export const Fetch: ArtalkPlugin = (ctx) => { + const conf = ctx.inject('config') + ctx.on('list-fetch', (_params) => { if (ctx.getData().getLoading()) return ctx.getData().setLoading(true) @@ -8,9 +10,9 @@ export const Fetch: ArtalkPlugin = (ctx) => { const params: ListFetchParams = { // default params offset: 0, - limit: ctx.conf.pagination.pageSize, - flatMode: ctx.conf.flatMode as boolean, // always be boolean because had been handled in Artalk.init - paramsModifier: ctx.conf.listFetchParamsModifier, + limit: conf.get().pagination.pageSize, + flatMode: conf.get().flatMode as boolean, // always be boolean because had been handled in Artalk.init + paramsModifier: conf.get().listFetchParamsModifier, ..._params, } @@ -24,8 +26,8 @@ export const Fetch: ArtalkPlugin = (ctx) => { limit: params.limit, offset: params.offset, flat_mode: params.flatMode, - page_key: ctx.getConf().pageKey, - site_name: ctx.getConf().site, + page_key: conf.get().pageKey, + site_name: conf.get().site, } // call the modifier function diff --git a/ui/artalk/src/plugins/list/goto-first.ts b/ui/artalk/src/plugins/list/goto-first.ts index e8410728..82689498 100644 --- a/ui/artalk/src/plugins/list/goto-first.ts +++ b/ui/artalk/src/plugins/list/goto-first.ts @@ -3,12 +3,13 @@ import * as Utils from '@/lib/utils' /** List scroll to the first comment */ export const GotoFirst: ArtalkPlugin = (ctx) => { - const handler = () => { - const list = ctx.get('list') + const list = ctx.inject('list') + const conf = ctx.inject('config') - const $relative = ctx.conf.scrollRelativeTo && ctx.conf.scrollRelativeTo() + const handler = () => { + const $relative = conf.get().scrollRelativeTo?.() ;($relative || window).scroll({ - top: Utils.getOffset(list.$el, $relative).top, + top: Utils.getOffset(list.getEl(), $relative).top, left: 0, }) } diff --git a/ui/artalk/src/plugins/list/goto-focus.ts b/ui/artalk/src/plugins/list/goto-focus.ts index 31ecf850..91eb234e 100644 --- a/ui/artalk/src/plugins/list/goto-focus.ts +++ b/ui/artalk/src/plugins/list/goto-focus.ts @@ -1,16 +1,15 @@ import type { ArtalkPlugin } from '@/types' export const GotoFocus: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') + ctx.on('list-goto', async (commentID) => { // find the comment node let comment = ctx.getCommentNodes().find((c) => c.getID() === commentID) if (!comment) { // fetch and insert the comment from the server const data = (await ctx.getApi().comments.getComment(commentID)).data - ctx - .get('list') - .getListLayout({ forceFlatMode: true }) - .insert(data.comment, data.reply_comment) + list.getLayout({ forceFlatMode: true }).insert(data.comment, data.reply_comment) comment = ctx.getCommentNodes().find((c) => c.getID() === commentID) } if (!comment) return diff --git a/ui/artalk/src/plugins/list/loading.ts b/ui/artalk/src/plugins/list/loading.ts index 311114f4..ca3e1ee7 100644 --- a/ui/artalk/src/plugins/list/loading.ts +++ b/ui/artalk/src/plugins/list/loading.ts @@ -2,17 +2,16 @@ import type { ArtalkPlugin } from '@/types' import * as Ui from '@/lib/ui' export const Loading: ArtalkPlugin = (ctx) => { - ctx.on('list-fetch', (p) => { - const list = ctx.get('list') + const list = ctx.inject('list') + ctx.on('list-fetch', (p) => { if (p.offset === 0) // only show loading when fetch first page - Ui.setLoading(true, list.$el) + Ui.setLoading(true, list.getEl()) // else if not first page, show loading in paginator (code not there) }) ctx.on('list-fetched', () => { - const list = ctx.get('list') - Ui.setLoading(false, list.$el) + Ui.setLoading(false, list.getEl()) }) } diff --git a/ui/artalk/src/plugins/list/no-comment.ts b/ui/artalk/src/plugins/list/no-comment.ts index 34253b58..c0415c1b 100644 --- a/ui/artalk/src/plugins/list/no-comment.ts +++ b/ui/artalk/src/plugins/list/no-comment.ts @@ -3,9 +3,10 @@ import * as Utils from '@/lib/utils' import { sanitize } from '@/lib/sanitizer' export const NoComment: ArtalkPlugin = (ctx) => { - ctx.on('list-loaded', (comments) => { - const list = ctx.get('list')! + const list = ctx.inject('list') + const conf = ctx.inject('config') + ctx.on('list-loaded', (comments) => { // 无评论 const isNoComment = comments.length <= 0 let $noComment = list.getCommentsWrapEl().querySelector('.atk-list-no-comment') @@ -15,7 +16,7 @@ export const NoComment: ArtalkPlugin = (ctx) => { $noComment = Utils.createElement('

') // sanitize before set innerHTML - $noComment.innerHTML = sanitize(list.ctx.conf.noComment || list.ctx.$t('noComment')) + $noComment.innerHTML = sanitize(conf.get().noComment || ctx.$t('noComment')) list.getCommentsWrapEl().appendChild($noComment) } } else { diff --git a/ui/artalk/src/plugins/list/reach-bottom.ts b/ui/artalk/src/plugins/list/reach-bottom.ts index d7f66f03..fcee7533 100644 --- a/ui/artalk/src/plugins/list/reach-bottom.ts +++ b/ui/artalk/src/plugins/list/reach-bottom.ts @@ -1,10 +1,12 @@ import type { ArtalkPlugin } from '@/types' export const ReachBottom: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') + const conf = ctx.inject('config') let observer: IntersectionObserver | null = null const setupObserver = ($target: HTMLElement) => { - const scrollEvtAt = (ctx.conf.scrollRelativeTo && ctx.conf.scrollRelativeTo()) || null + const scrollEvtAt = conf.get().scrollRelativeTo?.() || null observer = new IntersectionObserver( ([entries]) => { @@ -35,8 +37,6 @@ export const ReachBottom: ArtalkPlugin = (ctx) => { ctx.on('list-loaded', () => { clearObserver() - const list = ctx.get('list') - // get the second last child const children = list.getCommentsWrapEl().childNodes const $target = children.length > 2 ? (children[children.length - 2] as HTMLElement) : null diff --git a/ui/artalk/src/plugins/list/sidebar-btn.ts b/ui/artalk/src/plugins/list/sidebar-btn.ts index 2f9c82e5..a1c1f0d3 100644 --- a/ui/artalk/src/plugins/list/sidebar-btn.ts +++ b/ui/artalk/src/plugins/list/sidebar-btn.ts @@ -2,11 +2,12 @@ import type { ArtalkPlugin } from '@/types' import $t from '@/i18n' export const SidebarBtn: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') let $openSidebarBtn: HTMLElement | null = null const syncByUser = () => { if (!$openSidebarBtn) return - const user = ctx.get('user').getData() + const user = ctx.inject('user').getData() // 已输入个人信息 if (!!user.name && !!user.email) { @@ -21,9 +22,7 @@ export const SidebarBtn: ArtalkPlugin = (ctx) => { } ctx.watchConf(['locale'], (conf) => { - const list = ctx.get('list') - - $openSidebarBtn = list.$el.querySelector('[data-action="open-sidebar"]') + $openSidebarBtn = list.getEl().querySelector('[data-action="open-sidebar"]') if (!$openSidebarBtn) return $openSidebarBtn.onclick = () => { diff --git a/ui/artalk/src/plugins/list/time-ticking.ts b/ui/artalk/src/plugins/list/time-ticking.ts index e927568f..2c8ceb20 100644 --- a/ui/artalk/src/plugins/list/time-ticking.ts +++ b/ui/artalk/src/plugins/list/time-ticking.ts @@ -3,16 +3,19 @@ import * as Utils from '@/lib/utils' /** 评论时间自动更新 */ export const TimeTicking: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') + const conf = ctx.inject('config') let timer: number | null = null ctx.on('mounted', () => { timer = window.setInterval(() => { - const list = ctx.get('list') - - list.$el.querySelectorAll('[data-atk-comment-date]').forEach((el) => { - const date = new Date(Number(el.getAttribute('data-atk-comment-date'))) - el.innerText = ctx.getConf().dateFormatter?.(date) || Utils.timeAgo(date, ctx.$t) - }) + list + .getEl() + .querySelectorAll('[data-atk-comment-date]') + .forEach((el) => { + const date = new Date(Number(el.getAttribute('data-atk-comment-date'))) + el.innerText = conf.get().dateFormatter?.(date) || Utils.timeAgo(date, ctx.$t) + }) }, 30 * 1000) // 30s 更新一次 }) diff --git a/ui/artalk/src/plugins/list/unread-badge.ts b/ui/artalk/src/plugins/list/unread-badge.ts index b79edf78..1d75f86a 100644 --- a/ui/artalk/src/plugins/list/unread-badge.ts +++ b/ui/artalk/src/plugins/list/unread-badge.ts @@ -1,6 +1,7 @@ import type { ArtalkPlugin } from '@/types' export const UnreadBadge: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') let $unreadBadge: HTMLElement | null = null const showUnreadBadge = (count: number) => { @@ -15,9 +16,7 @@ export const UnreadBadge: ArtalkPlugin = (ctx) => { } ctx.on('mounted', () => { - const list = ctx.get('list') - - $unreadBadge = list.$el.querySelector('.atk-unread-badge') + $unreadBadge = list.getEl().querySelector('.atk-unread-badge') }) ctx.on('notifies-updated', (notifies) => { diff --git a/ui/artalk/src/plugins/list/unread.ts b/ui/artalk/src/plugins/list/unread.ts index 5eb59692..4f9eb8c3 100644 --- a/ui/artalk/src/plugins/list/unread.ts +++ b/ui/artalk/src/plugins/list/unread.ts @@ -2,9 +2,11 @@ import type { ArtalkPlugin } from '@/types' import * as Utils from '@/lib/utils' export const Unread: ArtalkPlugin = (ctx) => { + const conf = ctx.inject('config') + ctx.on('comment-rendered', (comment) => { // comment unread highlight - if (ctx.conf.listUnreadHighlight === true) { + if (conf.get().listUnreadHighlight) { const notifies = ctx.getData().getNotifies() const notify = notifies.find((o) => o.comment_id === comment.getID()) diff --git a/ui/artalk/src/plugins/list/with-editor.ts b/ui/artalk/src/plugins/list/with-editor.ts index bd2bb603..44a1a109 100644 --- a/ui/artalk/src/plugins/list/with-editor.ts +++ b/ui/artalk/src/plugins/list/with-editor.ts @@ -1,15 +1,17 @@ -import type { ContextApi, ArtalkPlugin, PageData } from '@/types' +import type { Context as IContext, ArtalkPlugin, PageData } from '@/types' import $t from '@/i18n' export const WithEditor: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') + const editorPlugs = ctx.inject('editorPlugs') let $closeCommentBtn: HTMLElement | undefined // on Artalk mounted // (after all components had mounted) ctx.on('mounted', () => { - const list = ctx.get('list') - - $closeCommentBtn = list.$el.querySelector('[data-action="admin-close-comment"]')! + $closeCommentBtn = list + .getEl() + .querySelector('[data-action="admin-close-comment"]')! // bind editor close button click event $closeCommentBtn.addEventListener('click', () => { @@ -23,16 +25,14 @@ export const WithEditor: ArtalkPlugin = (ctx) => { // on comment list loaded (it will include page data update) ctx.on('page-loaded', (page) => { - const editor = ctx.get('editor') - // if page comment is closed if (page?.admin_only === true) { // then close editor - editor.getPlugs()?.getEvents().trigger('editor-close') + editorPlugs?.getEvents().trigger('editor-close') $closeCommentBtn && ($closeCommentBtn.innerText = $t('openComment')) } else { // the open editor - editor.getPlugs()?.getEvents().trigger('editor-open') + editorPlugs?.getEvents().trigger('editor-open') $closeCommentBtn && ($closeCommentBtn.innerText = $t('closeComment')) } }) @@ -44,7 +44,7 @@ export const WithEditor: ArtalkPlugin = (ctx) => { } /** 管理员设置页面信息 */ -function adminPageEditSave(ctx: ContextApi, page: PageData) { +function adminPageEditSave(ctx: IContext, page: PageData) { ctx.editorShowLoading() ctx .getApi() diff --git a/ui/artalk/src/plugins/markdown.ts b/ui/artalk/src/plugins/markdown.ts index ea10f5a4..4be4636c 100644 --- a/ui/artalk/src/plugins/markdown.ts +++ b/ui/artalk/src/plugins/markdown.ts @@ -4,8 +4,8 @@ import * as marked from '@/lib/marked' export const Markdown: ArtalkPlugin = (ctx) => { ctx.watchConf(['imgLazyLoad', 'markedOptions'], (conf) => { marked.initMarked({ - markedOptions: ctx.getConf().markedOptions, - imgLazyLoad: ctx.getConf().imgLazyLoad, + markedOptions: conf.markedOptions, + imgLazyLoad: conf.imgLazyLoad, }) }) diff --git a/ui/artalk/src/plugins/mount-error.ts b/ui/artalk/src/plugins/mount-error.ts new file mode 100644 index 00000000..3828dc19 --- /dev/null +++ b/ui/artalk/src/plugins/mount-error.ts @@ -0,0 +1,40 @@ +import { showErrorDialog } from '@/components/error-dialog' +import type { ArtalkPlugin } from '@/types' + +export interface MountErrorOptions { + err?: { data?: { err_no_site?: boolean }; msg: string } + onRetry?: () => void +} + +export const MountError: ArtalkPlugin = (ctx, opts = {}) => { + const list = ctx.inject('list') + const user = ctx.inject('user') + const conf = ctx.inject('config') + + const err = opts.err + if (!err) throw new Error('MountError: `err` is required') + + let sidebarOpenView = '' + + // if response err_no_site, modify the sidebar open view to create site + if (err.data?.err_no_site) { + const viewLoadParam = { + create_name: conf.get().site, + create_urls: `${window.location.protocol}//${window.location.host}`, + } + sidebarOpenView = `sites|${JSON.stringify(viewLoadParam)}` + } + + showErrorDialog({ + $err: list.getEl(), + errMsg: err.msg || String(err), + errData: err.data, + retryFn: () => opts.onRetry?.(), + onOpenSidebar: user.getData().is_admin + ? () => + ctx.showSidebar({ + view: sidebarOpenView as any, + }) + : undefined, // only show open sidebar button when user is admin + }) +} diff --git a/ui/artalk/src/plugins/page-vote.ts b/ui/artalk/src/plugins/page-vote.ts index 564df93d..8858f82c 100644 --- a/ui/artalk/src/plugins/page-vote.ts +++ b/ui/artalk/src/plugins/page-vote.ts @@ -40,6 +40,7 @@ interface PageVoteState { } export const PageVoteWidget: ArtalkPlugin = (ctx) => { + const conf = ctx.inject('config') let state: PageVoteState = initState() let cleanup: (() => void) | undefined @@ -63,7 +64,7 @@ export const PageVoteWidget: ArtalkPlugin = (ctx) => { // List fetched handler let currPageId = 0 function listFetchedHandler({ data }: ListFetchedArgs) { - if (!ctx.getConf().pageVote || !checkEls(state) || !data) return + if (!conf.get().pageVote || !checkEls(state) || !data) return if (currPageId !== data.page.id) { // Initialize vote status in a new page diff --git a/ui/artalk/src/plugins/stat.ts b/ui/artalk/src/plugins/stat.ts index 89318d38..07abb8db 100644 --- a/ui/artalk/src/plugins/stat.ts +++ b/ui/artalk/src/plugins/stat.ts @@ -1,4 +1,4 @@ -import type { ContextApi, ArtalkPlugin, ArtalkConfig } from '@/types' +import type { Context, ArtalkPlugin } from '@/types' import { Api } from '@/api' type CountCache = { [pageKey: string]: number } @@ -17,19 +17,22 @@ export interface CountOptions { pvAdd?: boolean } -export const PvCountWidget: ArtalkPlugin = (ctx: ContextApi) => { - ctx.watchConf(['site', 'pageKey', 'pageTitle', 'countEl', 'pvEl', 'statPageKeyAttr'], (conf) => { - initCountWidget({ - getApi: () => ctx.getApi(), - siteName: conf.site, - pageKey: conf.pageKey, - pageTitle: conf.pageTitle, - countEl: conf.countEl, - pvEl: conf.pvEl, - pageKeyAttr: conf.statPageKeyAttr, - pvAdd: typeof ctx.conf.pvAdd === 'boolean' ? ctx.conf.pvAdd : true, - }) - }) +export const PvCountWidget: ArtalkPlugin = (ctx: Context) => { + ctx.watchConf( + ['site', 'pageKey', 'pageTitle', 'countEl', 'pvEl', 'statPageKeyAttr', 'pvAdd'], + (conf) => { + initCountWidget({ + getApi: () => ctx.getApi(), + siteName: conf.site, + pageKey: conf.pageKey, + pageTitle: conf.pageTitle, + countEl: conf.countEl, + pvEl: conf.pvEl, + pageKeyAttr: conf.statPageKeyAttr, + pvAdd: conf.pvAdd, + }) + }, + ) } /** Initialize count widgets */ diff --git a/ui/artalk/src/plugins/version-check.ts b/ui/artalk/src/plugins/version-check.ts index 2f68ba00..9e53f297 100644 --- a/ui/artalk/src/plugins/version-check.ts +++ b/ui/artalk/src/plugins/version-check.ts @@ -1,15 +1,13 @@ -import type { ArtalkPlugin } from '@/types' +import type { ArtalkPlugin, List } from '@/types' import { version as ARTALK_VERSION } from '~/package.json' -import type List from '~/src/list/list' -import * as Ui from '@/lib/ui' import * as Utils from '@/lib/utils' import $t from '@/i18n' let IgnoreVersionCheck = false export const VersionCheck: ArtalkPlugin = (ctx) => { + const list = ctx.inject('list') ctx.watchConf(['apiVersion', 'versionCheck'], (conf) => { - const list = ctx.get('list') if (conf.apiVersion && conf.versionCheck && !IgnoreVersionCheck) versionCheck(list, ARTALK_VERSION, conf.apiVersion) }) @@ -33,5 +31,5 @@ function versionCheck(list: List, feVer: string, beVer: string) { IgnoreVersionCheck = true } errEl.append(ignoreBtn) - list.$el.parentElement!.prepend(errEl) + list.getEl().parentElement!.prepend(errEl) } diff --git a/ui/artalk/src/service.ts b/ui/artalk/src/service.ts deleted file mode 100644 index 1fa9a32e..00000000 --- a/ui/artalk/src/service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import CheckerLauncher from './components/checker' -import Editor from './editor/editor' -import SidebarLayer from './layer/sidebar-layer' - -import List from './list/list' - -import * as I18n from './i18n' -import { PlugManager } from './plugins/editor-kit' -import { LayerManager } from './layer/layer-manager' -import User from './lib/user' -import type { ContextApi } from '@/types' - -/** - * Services - * - * @description Call these services by `ctx.get('serviceName')` or `ctx.serviceName` - */ -const services = { - // I18n - i18n(ctx: ContextApi) { - I18n.setLocale(ctx.conf.locale) - - ctx.watchConf(['locale'], (conf) => { - I18n.setLocale(conf.locale) - }) - }, - - // User Store - user(ctx: ContextApi) { - const user = new User({ - onUserChanged: (data) => { - ctx.trigger('user-changed', data) - }, - }) - return user - }, - - // 弹出层 - layerManager(ctx: ContextApi) { - return new LayerManager(ctx) - }, - - // CheckerLauncher - checkerLauncher(ctx: ContextApi) { - const checkerLauncher = new CheckerLauncher({ - getCtx: () => ctx, - getApi: () => ctx.getApi(), - onReload: () => ctx.reload(), - - // make sure suffix with a slash, because it will be used as a base url when call `fetch` - getCaptchaIframeURL: () => `${ctx.conf.server}/api/v2/captcha/?t=${+new Date()}`, - }) - return checkerLauncher - }, - - // 编辑器 - editor(ctx: ContextApi) { - const editor = new Editor(ctx) - ctx.$root.appendChild(editor.$el) - return editor - }, - - // 评论列表 - list(ctx: ContextApi): List { - const list = new List(ctx) - ctx.$root.appendChild(list.$el) - return list - }, - - // 侧边栏 Layer - sidebarLayer(ctx: ContextApi) { - const sidebarLayer = new SidebarLayer(ctx) - return sidebarLayer - }, - - // Extra Service - // ----------------------------------------- - // Only for type check - // Not inject to ctx immediately, - // but can be injected by other occasions - - editorPlugs(): PlugManager | undefined { - return undefined - }, -} - -export default services - -// type tricks for dependency injection -type TServiceImps = typeof services -type TObjectWithFuncs = { [k: string]: (...args: any) => any } -type TKeysOnlyReturn = { - [K in keyof T]: ReturnType extends V ? K : never -}[keyof T] -type TOmitConditions = TKeysOnlyReturn -type TServiceInjectors = Omit -export type TInjectedServices = { - [K in keyof TServiceInjectors]: ReturnType -} diff --git a/ui/artalk/src/services/api.ts b/ui/artalk/src/services/api.ts new file mode 100644 index 00000000..c891f467 --- /dev/null +++ b/ui/artalk/src/services/api.ts @@ -0,0 +1,15 @@ +import type { ArtalkPlugin } from '@/types' +import { Api, createApiHandlers } from '@/api' +import { convertApiOptions } from '@/config' + +export const ApiService: ArtalkPlugin = (ctx) => { + ctx.provide('apiHandlers', () => createApiHandlers(), [] as const) + ctx.provide( + 'api', + (user, config, apiHandlers) => new Api(convertApiOptions(config.get(), user, apiHandlers)), + ['user', 'config', 'apiHandlers'] as const, + { + lifecycle: 'transient', + }, + ) +} diff --git a/ui/artalk/src/services/checkers.ts b/ui/artalk/src/services/checkers.ts new file mode 100644 index 00000000..2e7b6978 --- /dev/null +++ b/ui/artalk/src/services/checkers.ts @@ -0,0 +1,25 @@ +import type { ArtalkPlugin } from '@/types' +import { CheckerLauncher } from '@/components/checker' + +export const CheckersService: ArtalkPlugin = (ctx) => { + ctx.provide( + 'checkers', + (api, apiHandlers, layers, user, config) => { + const checkers = new CheckerLauncher({ + getApi: () => api, + getLayers: () => layers, + getUser: () => user, + onReload: () => ctx.reload(), + + // make sure suffix with a slash, because it will be used as a base url when call `fetch` + getCaptchaIframeURL: () => `${config.get().server}/api/v2/captcha/?t=${+new Date()}`, + }) + + apiHandlers.add('need_captcha', (res) => checkers.checkCaptcha(res)) + apiHandlers.add('need_login', () => checkers.checkAdmin({})) + + return checkers + }, + ['api', 'apiHandlers', 'layers', 'user', 'config'] as const, + ) +} diff --git a/ui/artalk/src/services/config.ts b/ui/artalk/src/services/config.ts new file mode 100644 index 00000000..06da8392 --- /dev/null +++ b/ui/artalk/src/services/config.ts @@ -0,0 +1,33 @@ +import type { Config, ArtalkPlugin, ConfigManager } from '@/types' +import { mergeDeep } from '@/lib/merge-deep' +import { handelCustomConf } from '@/config' +import { watchConf } from '@/lib/watch-conf' + +export const ConfigService: ArtalkPlugin = (ctx) => { + let conf: Config = handelCustomConf({}, true) + + ctx.provide( + 'config', + (events) => { + let mounted = false + events.on('mounted', () => (mounted = true)) + const instance: ConfigManager = { + watchConf: (keys, effect) => { + watchConf({ + keys, + effect, + getConf: () => conf, + getEvents: () => events, + }) + }, + get: () => conf, + update: (config) => { + conf = mergeDeep(conf, handelCustomConf(config, false)) + mounted && events.trigger('updated', conf) + }, + } + return instance + }, + ['events'] as const, + ) +} diff --git a/ui/artalk/src/services/data.ts b/ui/artalk/src/services/data.ts new file mode 100644 index 00000000..bde11fb5 --- /dev/null +++ b/ui/artalk/src/services/data.ts @@ -0,0 +1,13 @@ +import { ArtalkPlugin } from '@/types' +import { DataManager } from '@/data' + +export const DataService: ArtalkPlugin = (ctx) => { + ctx.provide('data', (events) => new DataManager(events), ['events'] as const) + + ctx.on('mounted', () => { + // Load comment list immediately after mounting + if (ctx.getConf().fetchCommentsOnInit) { + ctx.getData().fetchComments({ offset: 0 }) + } + }) +} diff --git a/ui/artalk/src/services/editor.ts b/ui/artalk/src/services/editor.ts new file mode 100644 index 00000000..6273ee41 --- /dev/null +++ b/ui/artalk/src/services/editor.ts @@ -0,0 +1,17 @@ +import type { ArtalkPlugin } from '@/types' +import { Editor } from '@/editor/editor' + +export const EditorService: ArtalkPlugin = (ctx) => { + ctx.provide( + 'editor', + (events, config) => { + const editor = new Editor({ + getEvents: () => events, + getConf: () => config, + }) + ctx.getEl().appendChild(editor.getEl()) + return editor + }, + ['events', 'config'] as const, + ) +} diff --git a/ui/artalk/src/services/events.ts b/ui/artalk/src/services/events.ts new file mode 100644 index 00000000..88606315 --- /dev/null +++ b/ui/artalk/src/services/events.ts @@ -0,0 +1,6 @@ +import type { ArtalkPlugin, EventPayloadMap } from '@/types' +import { EventManager } from '@/lib/event-manager' + +export const EventsService: ArtalkPlugin = (ctx) => { + ctx.provide('events', () => new EventManager()) +} diff --git a/ui/artalk/src/services/i18n.ts b/ui/artalk/src/services/i18n.ts new file mode 100644 index 00000000..ada92b9c --- /dev/null +++ b/ui/artalk/src/services/i18n.ts @@ -0,0 +1,8 @@ +import * as I18n from '@/i18n' +import type { ArtalkPlugin } from '@/types' + +export const I18nService: ArtalkPlugin = (ctx) => { + ctx.watchConf(['locale'], (conf) => { + I18n.setLocale(conf.locale) + }) +} diff --git a/ui/artalk/src/services/index.ts b/ui/artalk/src/services/index.ts new file mode 100644 index 00000000..b097e3c6 --- /dev/null +++ b/ui/artalk/src/services/index.ts @@ -0,0 +1,22 @@ +import { ApiService } from './api' +import { UserService } from './user' +import { CheckersService } from './checkers' +import { EditorService } from './editor' +import { I18nService } from './i18n' +import { DataService } from './data' +import { LayersService } from './layer' +import { ListService } from './list' +import { SidebarService } from './sidebar' +import { ArtalkPlugin } from '@/types' + +export const Services: Set = new Set([ + DataService, + ApiService, + I18nService, + UserService, + EditorService, + ListService, + CheckersService, + LayersService, + SidebarService, +]) diff --git a/ui/artalk/src/services/layer.ts b/ui/artalk/src/services/layer.ts new file mode 100644 index 00000000..b06eee30 --- /dev/null +++ b/ui/artalk/src/services/layer.ts @@ -0,0 +1,13 @@ +import type { ArtalkPlugin } from '@/types' +import { LayerManager } from '@/layer' + +export const LayersService: ArtalkPlugin = (ctx) => { + ctx.provide('layers', () => { + const layerManager = new LayerManager() + document.body.appendChild(layerManager.getEl()) + ctx.on('unmounted', () => { + layerManager?.destroy() + }) + return layerManager + }) +} diff --git a/ui/artalk/src/services/list.ts b/ui/artalk/src/services/list.ts new file mode 100644 index 00000000..23fd0a4e --- /dev/null +++ b/ui/artalk/src/services/list.ts @@ -0,0 +1,24 @@ +import type { ArtalkPlugin } from '@/types' +import { List } from '@/list/list' + +export const ListService: ArtalkPlugin = (ctx) => { + ctx.provide( + 'list', + (api, events, config, data, editor) => { + const list = new List({ + getApi: () => api, + getEvents: () => events, + getConf: () => config, + getData: () => data, + + replyComment: (c, $el) => editor.setReplyComment(c, $el), + editComment: (c, $el) => editor.setEditComment(c, $el), + resetEditorState: () => editor.resetState(), + onListGotoFirst: () => ctx.listGotoFirst(), + }) + ctx.getEl().appendChild(list.getEl()) + return list + }, + ['api', 'events', 'config', 'data', 'editor'] as const, + ) +} diff --git a/ui/artalk/src/services/sidebar.ts b/ui/artalk/src/services/sidebar.ts new file mode 100644 index 00000000..074ae89f --- /dev/null +++ b/ui/artalk/src/services/sidebar.ts @@ -0,0 +1,37 @@ +import type { ArtalkPlugin } from '@/types' +import { SidebarLayer } from '@/layer/sidebar-layer' + +export const SidebarService: ArtalkPlugin = (ctx) => { + ctx.provide( + 'sidebar', + (events, data, editor, checkers, api, config, user, layers) => { + const sidebarLayer = new SidebarLayer({ + onShow: () => { + setTimeout(() => { + data.updateNotifies([]) + }, 0) + + events.trigger('sidebar-show') + }, + onHide: () => { + // prevent comment box from being swallowed + editor.resetState() + + events.trigger('sidebar-hide') + }, + getCheckers: () => checkers, + getApi: () => api, + getConf: () => config, + getUser: () => user, + getLayers: () => layers, + }) + + ctx.on('user-changed', () => { + sidebarLayer?.onUserChanged() + }) + + return sidebarLayer + }, + ['events', 'data', 'editor', 'checkers', 'api', 'config', 'user', 'layers'] as const, + ) +} diff --git a/ui/artalk/src/services/user.ts b/ui/artalk/src/services/user.ts new file mode 100644 index 00000000..dfa1e367 --- /dev/null +++ b/ui/artalk/src/services/user.ts @@ -0,0 +1,16 @@ +import type { ArtalkPlugin } from '@/types' +import { UserManager } from '@/lib/user' + +export const UserService: ArtalkPlugin = (ctx) => { + ctx.provide( + 'user', + () => { + return new UserManager({ + onUserChanged: (data) => { + ctx.trigger('user-changed', data) + }, + }) + }, + [] as const, + ) +} diff --git a/ui/artalk/src/types/checker.ts b/ui/artalk/src/types/checker.ts new file mode 100644 index 00000000..d25517f4 --- /dev/null +++ b/ui/artalk/src/types/checker.ts @@ -0,0 +1,12 @@ +import type { + Checker, + CheckerCaptchaPayload, + CheckerCtx, + CheckerPayload, +} from '@/components/checker' + +export interface CheckerManager { + checkCaptcha: (payload: CheckerCaptchaPayload) => Promise + checkAdmin: (payload: CheckerPayload) => Promise + check: (checker: Checker, payload: CheckerPayload, beforeCheck?: (c: CheckerCtx) => void) => void +} diff --git a/ui/artalk/src/types/config.ts b/ui/artalk/src/types/config.ts index 5ef158fe..d2cd3644 100644 --- a/ui/artalk/src/types/config.ts +++ b/ui/artalk/src/types/config.ts @@ -1,9 +1,10 @@ import type { MarkedOptions } from 'marked' import type { CommentData } from './data' -import type { EditorApi } from './editor' +import type { Editor } from './editor' +import type { Context } from './context' import type { I18n } from '@/i18n' -export interface ArtalkConfig { +export interface Config { /** Element selector or Element to mount the Artalk */ el: string | HTMLElement @@ -133,7 +134,7 @@ export interface ArtalkConfig { imgUploader?: (file: File) => Promise /** Image lazy load mode */ - imgLazyLoad?: 'native' | 'data-src' + imgLazyLoad: false | 'native' | 'data-src' /** Enable version check */ versionCheck: boolean @@ -145,7 +146,7 @@ export interface ArtalkConfig { locale: I18n | string /** Backend API version (system data, not allowed for user modification) */ - apiVersion?: string + apiVersion: string /** URLs for plugin scripts */ pluginURLs?: string[] @@ -166,47 +167,33 @@ export interface ArtalkConfig { */ dateFormatter?: (date: Date) => string - /** Custom configuration modifier for remote configuration (referenced by artalk-sidebar) */ - // TODO: Consider merging list-related configuration into a single object, or flatten everything for simplicity and consistency - remoteConfModifier?: (conf: DeepPartial) => void - /** List unread highlight (enable by default in artalk-sidebar) */ - listUnreadHighlight?: boolean + listUnreadHighlight: boolean /** The relative element for scrolling (useful if artalk is in a scrollable container) */ scrollRelativeTo?: () => HTMLElement /** Page view increment when comment list is loaded */ - pvAdd?: boolean + pvAdd: boolean + + /** Immediately fetch comments when Artalk instance is initialized */ + fetchCommentsOnInit: boolean /** Callback before submitting a comment */ - beforeSubmit?: (editor: EditorApi, next: () => void) => void + beforeSubmit?: (editor: Editor, next: () => void) => void } type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] } -export type ArtalkConfigPartial = DeepPartial - -/** - * Local User Data (in localStorage) - * - * @note Keep flat for easy handling - */ -export interface LocalUser { - /** Username (aka. Nickname) */ - name: string - - /** Email */ - email: string - - /** Link (aka. Website) */ - link: string +export type ConfigPartial = DeepPartial - /** Token (for authorization) */ - token: string +// Alias for backward compatibility +export type ArtalkConfig = Config - /** Admin flag */ - is_admin: boolean +export interface ConfigManager { + watchConf: Context['watchConf'] + get: () => Config + update: (conf: ConfigPartial) => void } diff --git a/ui/artalk/src/types/context.ts b/ui/artalk/src/types/context.ts index a3417334..06e201d4 100644 --- a/ui/artalk/src/types/context.ts +++ b/ui/artalk/src/types/context.ts @@ -2,16 +2,18 @@ import type { Marked } from 'marked' import type { SidebarShowPayload, EventPayloadMap, - ArtalkConfigPartial, - ArtalkConfig, + ConfigPartial, + Config, CommentData, - DataManagerApi, + DataManager, ListFetchParams, NotifyLevel, + Services, + EventManager, + UserManager, } from '.' -import type { TInjectedServices } from '@/service' import type { CheckerCaptchaPayload, CheckerPayload } from '@/components/checker' -import type { EventManagerFuncs } from '@/lib/event-manager' +import type { DependencyContainer } from '@/lib/injection' import type { I18n } from '@/i18n' import type { Api, ApiHandlers } from '@/api' import type { CommentNode } from '@/comment' @@ -19,45 +21,74 @@ import type { CommentNode } from '@/comment' /** * Artalk Context */ -export interface ContextApi extends EventManagerFuncs { - /** Artalk 根元素对象 */ +export interface Context extends EventManager, DependencyContainer { + /** + * The root element of Artalk + * + * @deprecated Use `getEl()` instead + */ $root: HTMLElement - /** 依赖注入函数 */ - inject(depName: K, obj: TInjectedServices[K]): void + /** Get the root element */ + getEl(): HTMLElement + + /** + * Inject a dependency object + * + * @deprecated Use `inject()` instead + */ + get(key: T): Services[T] + + /** + * Get config object + * + * @deprecated Use `getConf()` and `updateConf()` instead + */ + conf: Config + + /** Get the config */ + getConf(): Config - /** 获取依赖对象 */ - get(depName: K): TInjectedServices[K] + /** Update the config */ + updateConf(conf: ConfigPartial): void - /** 配置对象 */ - // TODO: 修改为 getConf() 和 setConf() 并且返回拷贝而不是引用 - conf: ArtalkConfig + /** Watch the config */ + watchConf( + keys: T, + effect: (val: Pick) => void, + ): void - /** marked 依赖对象 */ + /** Get the marked instance */ getMarked(): Marked | undefined - /** 获取 API 以供 HTTP 请求 */ + /** Set dark mode */ + setDarkMode(darkMode: boolean | 'auto'): void + + /** Translate i18n message */ + $t(key: keyof I18n, args?: { [key: string]: string }): string + + /** Get HTTP API client */ getApi(): Api - /** Get API handlers */ + /** Get HTTP API handlers */ getApiHandlers(): ApiHandlers - /** 获取数据管理器对象 */ - getData(): DataManagerApi + /** Get Data Manager */ + getData(): DataManager - /** 评论回复 */ - replyComment(commentData: CommentData, $comment: HTMLElement): void + /** Get User Manager */ + getUser(): UserManager - /** 编辑评论 */ - editComment(commentData: CommentData, $comment: HTMLElement): void - - /** 获取评论数据 */ + /** Fetch comments */ fetch(params: Partial): void - /** 重载评论数据 */ + /** Reload comments */ reload(): void - /** 列表滚动到第一个评论的位置 */ + /** Destroy */ + destroy(): void + + /** Goto the first comment of the list */ listGotoFirst(): void /** Get the comment data list */ @@ -78,48 +109,33 @@ export interface ContextApi extends EventManagerFuncs { */ getCommentList(): CommentNode[] - /** 显示侧边栏 */ - showSidebar(payload?: SidebarShowPayload): void + /** Reply to a comment */ + replyComment(commentData: CommentData, $comment: HTMLElement): void - /** 隐藏侧边栏 */ - hideSidebar(): void + /** Edit a comment */ + editComment(commentData: CommentData, $comment: HTMLElement): void - /** 编辑器 - 显示加载 */ + /** Show loading of the editor */ editorShowLoading(): void - /** 编辑器 - 隐藏加载 */ + /** Hide loading of the editor */ editorHideLoading(): void - /** 编辑器 - 显示提示消息 */ + /** Show notify of the editor */ editorShowNotify(msg: string, type: NotifyLevel): void - /** 评论框 - 复原状态 */ + /** Reset the state of the editor */ editorResetState(): void - /** 验证码检测 */ - checkCaptcha(payload: CheckerCaptchaPayload): Promise - - /** 管理员检测 */ - checkAdmin(payload: CheckerPayload): Promise - - /** i18n 翻译 */ - $t(key: keyof I18n, args?: { [key: string]: string }): string - - /** 设置夜间模式 */ - setDarkMode(darkMode: boolean | 'auto'): void - - /** 获取配置 */ - getConf(): ArtalkConfig + /** Show the sidebar */ + showSidebar(payload?: SidebarShowPayload): void - /** 获取挂载元素 */ - getEl(): HTMLElement + /** Hide the sidebar */ + hideSidebar(): void - /** 更新配置 */ - updateConf(conf: ArtalkConfigPartial): void + /** Check captcha */ + checkCaptcha(payload: CheckerCaptchaPayload): Promise - /** 监听配置更新 */ - watchConf( - keys: T, - effect: (val: Pick) => void, - ): void + /** Check admin */ + checkAdmin(payload: CheckerPayload): Promise } diff --git a/ui/artalk/src/types/data.ts b/ui/artalk/src/types/data.ts index 28bd9c09..c7bb191d 100644 --- a/ui/artalk/src/types/data.ts +++ b/ui/artalk/src/types/data.ts @@ -219,7 +219,7 @@ export interface ListLastFetchData { data?: ListData } -export interface DataManagerApi { +export interface DataManager { getLoading(): boolean setLoading(val: boolean): void diff --git a/ui/artalk/src/types/editor.ts b/ui/artalk/src/types/editor.ts index 1572e351..e8760e40 100644 --- a/ui/artalk/src/types/editor.ts +++ b/ui/artalk/src/types/editor.ts @@ -1,10 +1,29 @@ -import type { CommentData, NotifyLevel } from '.' -import type Component from '@/lib/component' +import type { CommentData, NotifyLevel, EventManager } from '.' +import type { + EditorEventPayloadMap, + PluginManager, + PluginManagerOptions, +} from '@/plugins/editor-kit' +import type { EditorPlugin } from '@/plugins/editor/_plug' +import type { EditorOptions } from '@/editor/editor' import type { EditorUI } from '@/editor/ui' export type EditorState = 'reply' | 'edit' | 'normal' -export interface EditorApi extends Component { +export interface Editor { + /** + * Get editor options + */ + getOptions(): EditorOptions + + /** + * Get the editor element + */ + getEl(): HTMLElement + + /** + * Get the editor UI instance + */ getUI(): EditorUI /** @@ -84,12 +103,31 @@ export interface EditorApi extends Component { /** * Start replying a comment */ - setReply(commentData: CommentData, $comment: HTMLElement, scroll?: boolean): void + setReplyComment(commentData: CommentData, $comment: HTMLElement, scroll?: boolean): void /** * Start editing a comment */ setEditComment(commentData: CommentData, $comment: HTMLElement): void + + /** + * Get plugin manager + */ + getPlugins(): PluginManager | undefined + + /** + * Set plugin manager + */ + setPlugins(plugins: PluginManager): void } -export default EditorApi +export interface EditorPluginManager { + getPlugins: () => EditorPlugin[] + getEvents: () => EventManager + getEditor: () => Editor + getOptions: () => PluginManagerOptions + get(plug: T): InstanceType | undefined + openPluginPanel: (plug: EditorPlugin) => void + closePluginPanel: () => void + getTransformedContent: (rawContent: string) => string +} diff --git a/ui/artalk/src/types/event.ts b/ui/artalk/src/types/event.ts index 4669bd3c..c38791bb 100644 --- a/ui/artalk/src/types/event.ts +++ b/ui/artalk/src/types/event.ts @@ -1,5 +1,12 @@ -import { CommentData, ListData, ListFetchParams, NotifyData, PageData } from './data' -import { ArtalkConfig, LocalUser } from './config' +import type { + CommentData, + ListData, + ListFetchParams, + NotifyData, + PageData, + LocalUser, + Config, +} from '.' import type { CommentNode } from '@/comment' export interface ListErrorData { @@ -17,7 +24,7 @@ export interface EventPayloadMap { // Basic lifecycle created: undefined mounted: undefined - updated: ArtalkConfig + updated: Config unmounted: undefined 'list-fetch': Partial // 评论列表请求时 @@ -43,3 +50,20 @@ export interface EventPayloadMap { 'sidebar-show': undefined // 侧边栏显示 'sidebar-hide': undefined // 侧边栏隐藏 } + +export type EventHandler = (payload: T) => void + +export interface Event extends EventOptions { + name: K + handler: EventHandler +} + +export interface EventOptions { + once?: boolean +} + +export interface EventManager { + on(name: K, handler: EventHandler, opts?: EventOptions): void + off(name: K, handler: EventHandler): void + trigger(name: K, payload?: T[K]): void +} diff --git a/ui/artalk/src/types/index.ts b/ui/artalk/src/types/index.ts index e38be02b..4e6ba0a2 100644 --- a/ui/artalk/src/types/index.ts +++ b/ui/artalk/src/types/index.ts @@ -1,9 +1,14 @@ export type * from './config' export type * from './data' export type * from './context' +export type * from './user' +export type * from './list' export type * from './editor' export type * from './event' export type * from './plugin' export type * from './sidebar' export type * from './layer' +export type * from './checker' +export type * from './service' export type { I18n, I18nKeys } from '../i18n' +export type { Api, ApiHandlers } from '../api' diff --git a/ui/artalk/src/types/layer.ts b/ui/artalk/src/types/layer.ts index 4310819c..60b64c73 100644 --- a/ui/artalk/src/types/layer.ts +++ b/ui/artalk/src/types/layer.ts @@ -7,3 +7,9 @@ export interface Layer { getAllowMaskClose(): boolean getEl: () => HTMLElement } + +export interface LayerManager { + create(name: string, el?: HTMLElement): Layer + destroy(): void + getEl(): HTMLElement +} diff --git a/ui/artalk/src/types/list.ts b/ui/artalk/src/types/list.ts new file mode 100644 index 00000000..fa9d5415 --- /dev/null +++ b/ui/artalk/src/types/list.ts @@ -0,0 +1,14 @@ +import type { CommentNode } from '../comment' +import type { CommentData } from './data' + +export interface List { + getEl: () => HTMLElement + getCommentsWrapEl: () => HTMLElement + getLayout: (arg: { forceFlatMode?: boolean }) => ListLayout + getCommentNodes: () => CommentNode[] +} + +export interface ListLayout { + import: (comments: CommentData[]) => void + insert: (comment: CommentData, replyComment?: CommentData) => void +} diff --git a/ui/artalk/src/types/plugin.ts b/ui/artalk/src/types/plugin.ts index 2fae206d..f3614c7e 100644 --- a/ui/artalk/src/types/plugin.ts +++ b/ui/artalk/src/types/plugin.ts @@ -1,3 +1,3 @@ -import { ContextApi } from './context' +import { Context } from './context' -export type ArtalkPlugin = (ctx: ContextApi, options?: T) => void +export type ArtalkPlugin = (ctx: Context, options?: T) => void diff --git a/ui/artalk/src/types/service.ts b/ui/artalk/src/types/service.ts new file mode 100644 index 00000000..bf4b8558 --- /dev/null +++ b/ui/artalk/src/types/service.ts @@ -0,0 +1,29 @@ +import type { + DataManager, + Editor, + EventManager, + Api, + ApiHandlers, + ConfigManager, + UserManager, + LayerManager, + List, + SidebarLayer, + EditorPluginManager, + CheckerManager, +} from '@/types' + +export interface Services { + config: ConfigManager + events: EventManager + data: DataManager + api: Api + apiHandlers: ApiHandlers + editor: Editor + editorPlugs: EditorPluginManager | undefined + list: List + sidebar: SidebarLayer + checkers: CheckerManager + layers: LayerManager + user: UserManager +} diff --git a/ui/artalk/src/types/sidebar.ts b/ui/artalk/src/types/sidebar.ts index d3c805cd..c79d41c2 100644 --- a/ui/artalk/src/types/sidebar.ts +++ b/ui/artalk/src/types/sidebar.ts @@ -1,3 +1,9 @@ +export interface SidebarLayer { + onUserChanged: () => void + show: (conf?: SidebarShowPayload) => void + hide: () => void +} + export interface SidebarShowPayload { view?: 'comments' | 'sites' | 'pages' | 'transfer' } diff --git a/ui/artalk/src/types/user.ts b/ui/artalk/src/types/user.ts new file mode 100644 index 00000000..c9f6f869 --- /dev/null +++ b/ui/artalk/src/types/user.ts @@ -0,0 +1,28 @@ +/** + * Local User Data (in localStorage) + * + * @note Keep flat for easy handling + */ +export interface LocalUser { + /** Username (aka. Nickname) */ + name: string + + /** Email */ + email: string + + /** Link (aka. Website) */ + link: string + + /** Token (for authorization) */ + token: string + + /** Admin flag */ + is_admin: boolean +} + +export interface UserManager { + getData: () => LocalUser + update: (dta: Partial) => void + logout: () => void + checkHasBasicUserInfo: () => boolean +} diff --git a/ui/artalk/tests/ui-api.test.ts b/ui/artalk/tests/ui-api.test.ts index 5cd3c230..29ddc2d2 100644 --- a/ui/artalk/tests/ui-api.test.ts +++ b/ui/artalk/tests/ui-api.test.ts @@ -97,7 +97,7 @@ describe('Artalk instance', () => { it( 'should can listen to events and the conf-remoter works (artalk.trigger, artalk.on, conf-remoter)', async () => { - global.devLoadArtalk() + global.devMountArtalk() const fn = vi.fn()