diff --git a/packages/artalk-sidebar/src/admin/page-list.ts b/packages/artalk-sidebar/src/admin/page-list.ts index cf49a51a8..a1d9076a2 100644 --- a/packages/artalk-sidebar/src/admin/page-list.ts +++ b/packages/artalk-sidebar/src/admin/page-list.ts @@ -1,6 +1,6 @@ import '../style/page-list.less' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' import { PageData } from 'artalk/types/artalk-data' diff --git a/packages/artalk-sidebar/src/admin/site-list-floater.ts b/packages/artalk-sidebar/src/admin/site-list-floater.ts index 4240c4d0b..f3113b14a 100644 --- a/packages/artalk-sidebar/src/admin/site-list-floater.ts +++ b/packages/artalk-sidebar/src/admin/site-list-floater.ts @@ -1,6 +1,6 @@ import '../style/site-list.less' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' import { SiteData } from 'artalk/types/artalk-data' diff --git a/packages/artalk-sidebar/src/admin/site-list.ts b/packages/artalk-sidebar/src/admin/site-list.ts index 7f8928425..778cb9144 100644 --- a/packages/artalk-sidebar/src/admin/site-list.ts +++ b/packages/artalk-sidebar/src/admin/site-list.ts @@ -1,6 +1,6 @@ import '../style/site-list.less' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' import { SiteData } from 'artalk/types/artalk-data' diff --git a/packages/artalk-sidebar/src/main.ts b/packages/artalk-sidebar/src/main.ts index dc9e73804..ca8882775 100644 --- a/packages/artalk-sidebar/src/main.ts +++ b/packages/artalk-sidebar/src/main.ts @@ -11,11 +11,9 @@ export default async function InitSidebar(conf: ArtalkConfig, user: LocalUser, v // 初始化用户数据 user = user || {} - artalk.ctx.user.data = { - ...artalk.ctx.user.data, + artalk.ctx.user.update({ ...user - } - artalk.ctx.user.save() + }) // 初始化 Sidebar const sidebarCtx = new SidebarCtx() diff --git a/packages/artalk-sidebar/src/sidebar-component.ts b/packages/artalk-sidebar/src/sidebar-component.ts index e8166f263..4701f1e0a 100644 --- a/packages/artalk-sidebar/src/sidebar-component.ts +++ b/packages/artalk-sidebar/src/sidebar-component.ts @@ -1,5 +1,5 @@ import ArtalkComponent from 'artalk/src/lib/component' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import { SidebarCtx } from './main' export default class Component extends ArtalkComponent { diff --git a/packages/artalk-sidebar/src/sidebar-root.ts b/packages/artalk-sidebar/src/sidebar-root.ts index 9aa5da026..2550c956e 100644 --- a/packages/artalk-sidebar/src/sidebar-root.ts +++ b/packages/artalk-sidebar/src/sidebar-root.ts @@ -1,9 +1,9 @@ import './style/sidebar.less' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import { SiteData } from 'artalk/types/artalk-data' import Api from 'artalk/src/api' diff --git a/packages/artalk-sidebar/src/sidebar-view.ts b/packages/artalk-sidebar/src/sidebar-view.ts index e4923e929..3e449857f 100644 --- a/packages/artalk-sidebar/src/sidebar-view.ts +++ b/packages/artalk-sidebar/src/sidebar-view.ts @@ -1,6 +1,6 @@ -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import Component from './sidebar-component' import { SidebarCtx } from './main' diff --git a/packages/artalk-sidebar/src/sidebar-views/comments-view.ts b/packages/artalk-sidebar/src/sidebar-views/comments-view.ts index aa0fe7b0e..c0aa7dd62 100644 --- a/packages/artalk-sidebar/src/sidebar-views/comments-view.ts +++ b/packages/artalk-sidebar/src/sidebar-views/comments-view.ts @@ -1,6 +1,6 @@ -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' -import ListLite from 'artalk/src/components/list-lite' +import ListLite from 'artalk/src/list/list-lite' import SidebarView from '../sidebar-view' @@ -39,10 +39,10 @@ export default class MessageView extends SidebarView { this.list.pageMode = 'pagination' this.list.noCommentText = '
无内容
' this.list.renderComment = (comment) => { - const pageURL = comment.data.page_url - comment.setOpenURL(`${pageURL}#atk-comment-${comment.data.id}`) - comment.onReplyBtnClick = () => { - this.ctx.trigger('editor-reply', {data: comment.data, $el: comment.$el, scroll: true}) + const pageURL = comment.getData().page_url + comment.getRender().setOpenURL(`${pageURL}#atk-comment-${comment.getID()}`) + comment.getConf().onReplyBtnClick = () => { + this.ctx.trigger('editor-reply', {data: comment.getData(), $el: comment.getEl(), scroll: true}) } } this.list.paramsEditor = (params) => { diff --git a/packages/artalk-sidebar/src/sidebar-views/pages-view.ts b/packages/artalk-sidebar/src/sidebar-views/pages-view.ts index 9ca1a00ea..5d8c22859 100644 --- a/packages/artalk-sidebar/src/sidebar-views/pages-view.ts +++ b/packages/artalk-sidebar/src/sidebar-views/pages-view.ts @@ -1,8 +1,8 @@ import Api from 'artalk/src/api' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import Pagination, { PaginationConf } from 'artalk/src/components/pagination' import SidebarView from '../sidebar-view' diff --git a/packages/artalk-sidebar/src/sidebar-views/setting-view.ts b/packages/artalk-sidebar/src/sidebar-views/setting-view.ts index 867260baf..5544c855b 100644 --- a/packages/artalk-sidebar/src/sidebar-views/setting-view.ts +++ b/packages/artalk-sidebar/src/sidebar-views/setting-view.ts @@ -1,7 +1,7 @@ import Api from 'artalk/src/api' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import SiteList from '../admin/site-list' import SidebarView from '../sidebar-view' diff --git a/packages/artalk-sidebar/src/sidebar-views/sites-view.ts b/packages/artalk-sidebar/src/sidebar-views/sites-view.ts index 27d2f22ef..594ef5437 100644 --- a/packages/artalk-sidebar/src/sidebar-views/sites-view.ts +++ b/packages/artalk-sidebar/src/sidebar-views/sites-view.ts @@ -1,7 +1,7 @@ import Api from 'artalk/src/api' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import SiteList from '../admin/site-list' import SidebarView from '../sidebar-view' diff --git a/packages/artalk-sidebar/src/sidebar-views/transfer-view.ts b/packages/artalk-sidebar/src/sidebar-views/transfer-view.ts index 3e2caf356..4e3780040 100644 --- a/packages/artalk-sidebar/src/sidebar-views/transfer-view.ts +++ b/packages/artalk-sidebar/src/sidebar-views/transfer-view.ts @@ -1,8 +1,8 @@ import Api from 'artalk/src/api' -import Context from 'artalk/src/context' +import Context from 'artalk/types/context' import * as Utils from 'artalk/src/lib/utils' import * as Ui from 'artalk/src/lib/ui' -import Comment from 'artalk/src/components/comment' +import Comment from 'artalk/src/comment' import SiteList from '../admin/site-list' import SidebarView from '../sidebar-view' diff --git a/packages/artalk/package.json b/packages/artalk/package.json index ede069aea..71be563c3 100644 --- a/packages/artalk/package.json +++ b/packages/artalk/package.json @@ -25,7 +25,8 @@ "types": "./types/index.d.ts", "scripts": { "dev": "vite", - "build": "pnpm lint && vite build", + "build": "pnpm lint && vite build && pnpm build:lite", + "build:lite": "vite build --config vite-lite.config.ts", "build:demo": "vite build --config vite-demo.config.ts", "serve": "vite preview", "deploy": "gh-pages -d deploy", diff --git a/packages/artalk/src/api/index.ts b/packages/artalk/src/api/index.ts index 3d692c1e7..ebacd187c 100644 --- a/packages/artalk/src/api/index.ts +++ b/packages/artalk/src/api/index.ts @@ -1,6 +1,6 @@ import { CommentData, ListData, UserData, PageData, SiteData, NotifyData } from '~/types/artalk-data' import ArtalkConfig from '~/types/artalk-config' -import Context from '../context' +import Context from '~/types/context' import { Fetch, ToFormData, POST, GET } from './request' import * as Utils from '../lib/utils' import 'abortcontroller-polyfill/dist/polyfill-patch-fetch' diff --git a/packages/artalk/src/api/request.ts b/packages/artalk/src/api/request.ts index 60e0743b6..8dd4fd591 100644 --- a/packages/artalk/src/api/request.ts +++ b/packages/artalk/src/api/request.ts @@ -1,4 +1,4 @@ -import Context from '../context' +import Context from '~/types/context' /** 公共请求函数 */ export async function Fetch(ctx: Context, input: RequestInfo, init: RequestInit, timeout?: number): Promise { diff --git a/packages/artalk/src/artalk.ts b/packages/artalk/src/artalk.ts index 042d5d29f..9567fdb65 100644 --- a/packages/artalk/src/artalk.ts +++ b/packages/artalk/src/artalk.ts @@ -2,15 +2,16 @@ import './style/main.less' import ArtalkConfig from '~/types/artalk-config' import { EventPayloadMap, Handler } from '~/types/event' -import Context from './context' +import Context from '~/types/context' +import ConcreteContext from './context' import defaults from './defaults' import CheckerLauncher from './lib/checker' -import Editor from './components/editor' -import List from './components/list' -import SidebarLayer from './components/sidebar-layer' +import Editor from './editor' +import List from './list' +import SidebarLayer from './layer/sidebar-layer' -import Layer, { GetLayerWrap } from './components/layer' +import Layer, { GetLayerWrap } from './layer' import Api from './api' import * as Utils from './lib/utils' import * as Ui from './lib/ui' @@ -31,7 +32,7 @@ export default class Artalk { public list!: List public sidebarLayer!: SidebarLayer - constructor (customConf: ArtalkConfig) { + constructor(customConf: Partial) { // 配置 this.conf = Utils.mergeDeep(Artalk.defaults, customConf) this.conf.server = this.conf.server.replace(/\/$/, '').replace(/\/api\/?$/, '') @@ -67,7 +68,7 @@ export default class Artalk { } // Context 初始化 - this.ctx = new Context(this.$root, this.conf) + this.ctx = new ConcreteContext(this.$root, this.conf) // 界面初始化 this.$root.classList.add('artalk') diff --git a/packages/artalk/src/comment/actions.ts b/packages/artalk/src/comment/actions.ts new file mode 100644 index 000000000..fa50feebb --- /dev/null +++ b/packages/artalk/src/comment/actions.ts @@ -0,0 +1,87 @@ +import Api from '../api' +import Comment from './comment' +import ActionBtn from '../components/action-btn' + +export default class CommentActions { + private comment: Comment + + private get ctx() { return this.comment.ctx } + private get data() { return this.comment.getData() } + private get cConf() { return this.comment.getConf() } + + public constructor(comment: Comment) { + this.comment = comment + } + + /** 投票操作 */ + public vote(type: 'up'|'down') { + const actionBtn = (type === 'up') ? this.comment.getRender().voteBtnUp : this.comment.getRender().voteBtnDown + + new Api(this.ctx).vote(this.data.id, `comment_${type}`) + .then((v) => { + this.data.vote_up = v.up + this.data.vote_down = v.down + this.comment.getRender().voteBtnUp?.updateText() + this.comment.getRender().voteBtnDown?.updateText() + }) + .catch((err) => { + actionBtn?.setError(this.ctx.$t('voteFail')) + console.log(err) + }) + } + + /** 管理员 - 评论状态修改 */ + public adminEdit(type: 'collapsed'|'pending'|'pinned', btnElem: ActionBtn) { + if (btnElem.isLoading) return // 若正在修改中 + + btnElem.setLoading(true, `${this.ctx.$t('editing')}...`) + + // 克隆并修改当前数据 + const modify = { ...this.data } + if (type === 'collapsed') { + modify.is_collapsed = !modify.is_collapsed + } else if (type === 'pending') { + modify.is_pending = !modify.is_pending + } else if (type === 'pinned') { + modify.is_pinned = !modify.is_pinned + } + + new Api(this.ctx).commentEdit(modify).then((data) => { + btnElem.setLoading(false) + + // 刷新当前 Comment UI + this.comment.setData(data) + + // 刷新 List UI + this.ctx.trigger('list-refresh-ui') + }).catch((err) => { + console.error(err) + btnElem.setError(this.ctx.$t('editFail')) + }) + } + + /** 管理员 - 评论删除 */ + public adminDelete(btnElem: ActionBtn) { + if (btnElem.isLoading) return // 若正在删除中 + + btnElem.setLoading(true, `${this.ctx.$t('deleting')}...`) + new Api(this.ctx).commentDel(this.data.id, this.data.site_name) + .then(() => { + btnElem.setLoading(false) + if (this.cConf.onDelete) this.cConf.onDelete(this.comment) + }) + .catch((e) => { + console.error(e) + btnElem.setError(this.ctx.$t('deleteFail')) + }) + } + + /** 快速跳转到该评论 */ + public goToReplyComment() { + const origHash = window.location.hash + const modifyHash = `#atk-comment-${this.data.rid}` + + window.location.hash = modifyHash + if (modifyHash === origHash) window.dispatchEvent(new Event('hashchange')) // 强制触发事件 + } +} diff --git a/packages/artalk/src/components/html/comment.html b/packages/artalk/src/comment/comment.html similarity index 90% rename from packages/artalk/src/components/html/comment.html rename to packages/artalk/src/comment/comment.html index aae544a67..bfc3d7cee 100644 --- a/packages/artalk/src/components/html/comment.html +++ b/packages/artalk/src/comment/comment.html @@ -4,7 +4,7 @@
- +
diff --git a/packages/artalk/src/comment/comment.ts b/packages/artalk/src/comment/comment.ts new file mode 100644 index 000000000..4ebf49045 --- /dev/null +++ b/packages/artalk/src/comment/comment.ts @@ -0,0 +1,191 @@ +import { CommentData } from '~/types/artalk-data' +import Context from '~/types/context' +import Component from '../lib/component' +import * as Utils from '../lib/utils' +import UADetect from '../lib/detect' +import CommentRender from './render' +import CommentActions from './actions' + +export interface CommentConf { + isUnread?: boolean + openURL?: string + isFlatMode: boolean + replyTo?: CommentData + afterRender?: () => void + openEvt?: () => void + onReplyBtnClick?: Function + onDelete?: Function +} + +export default class Comment extends Component { + private renderInstance: CommentRender + private actionInstance: CommentActions + + private data: CommentData + private cConf: CommentConf + + private parent: Comment|null + private children: Comment[] = [] + + private nestCurt: number // 当前嵌套层数 + private nestMax: number // 最大嵌套层数 + + constructor(ctx: Context, data: CommentData, conf: CommentConf) { + super(ctx) + + // 最大嵌套数 + this.nestMax = ctx.conf.nestMax || 3 + + this.cConf = conf + this.data = { ...data } + this.data.date = this.data.date.replace(/-/g, '/') // 解决 Safari 日期解析 NaN 问题 + + this.parent = null + this.nestCurt = 1 // 现在已嵌套 n 层 + + this.actionInstance = new CommentActions(this) + this.renderInstance = new CommentRender(this) + } + + /** 渲染 UI */ + public render() { + const newEl = this.renderInstance.render() + + if (this.$el) this.$el.replaceWith(newEl) + this.$el = newEl + + if (this.cConf.afterRender) this.cConf.afterRender() + } + + /** 获取评论操作实例对象 */ + public getActions() { + return this.actionInstance + } + + /** 获取评论渲染器实例对象 */ + public getRender() { + return this.renderInstance + } + + /** 获取评论数据 */ + public getData() { + return this.data + } + + /** 设置数据 */ + public setData(data: CommentData) { + this.data = data + + this.render() + this.getRender().playFadeAnimForBody() + } + + /** 获取父评论 */ + public getParent() { + return this.parent + } + + /** 获取所有子评论 */ + public getChildren() { + return this.children + } + + /** 获取当前嵌套层数 */ + public getNestCurt() { + return this.nestCurt + } + + /** 判断是否为根评论 */ + public getIsRoot() { + return this.data.rid === 0 + } + + /** 获取评论 ID */ + public getID() { + return this.data.id + } + + /** 置入子评论 */ + public putChild(childC: Comment, insertMode: 'append'|'prepend' = 'append') { + childC.parent = this + childC.nestCurt = this.nestCurt + 1 // 嵌套层数 +1 + + this.children.push(childC) + + const $children = this.getChildrenEl() + if (insertMode === 'append') $children.append(childC.getEl()) + else if (insertMode === 'prepend') $children.prepend(childC.getEl()) + + childC.getRender().playFadeAnim() + + // 内容限高 + childC.getRender().checkHeightLimitArea('content') + } + + /** 获取存放子评论的元素对象 */ + public getChildrenEl(): HTMLElement { + let $children = this.getRender().getChildrenWrap() + + if (!$children) { + // console.log(this.nestCurt) + if (this.nestCurt < this.nestMax) { + $children = this.getRender().renderChildrenWrap() + } else { + $children = this.parent!.getChildrenEl() + } + } + + return $children + } + + /** 获取所有父评论 */ + public getParents() { + const parents: Comment[] = [] + const once = (c: Comment) => { + if (c.parent) { + parents.push(c.parent) + once(c.parent) + } + } + + once(this) + return parents + } + + /** 获取评论元素对象 */ + public getEl() { + return this.$el + } + + /** 获取 Gravatar 头像 URL */ + public getGravatarURL() { + return Utils.getGravatarURL(this.ctx, this.data.email_encrypted) + } + + /** 获取评论 markdown 解析后的内容 */ + public getContentMarked() { + return Utils.marked(this.ctx, this.data.content) + } + + /** 获取格式化后的日期 */ + public getDateFormatted() { + return Utils.timeAgo(new Date(this.data.date), this.ctx) + } + + /** 获取用户 UserAgent 浏览器 */ + public getUserUaBrowser() { + const info = UADetect(this.data.ua) + return `${info.browser} ${info.version}` + } + + /** 获取用户 UserAgent 系统 */ + public getUserUaOS() { + const info = UADetect(this.data.ua) + return `${info.os} ${info.osVersion}` + } + + /** 获取配置 */ + public getConf() { + return this.cConf + } +} diff --git a/packages/artalk/src/comment/index.ts b/packages/artalk/src/comment/index.ts new file mode 100644 index 000000000..babbf50cf --- /dev/null +++ b/packages/artalk/src/comment/index.ts @@ -0,0 +1,3 @@ +import Comment from './comment' + +export default Comment diff --git a/packages/artalk/src/comment/render.ts b/packages/artalk/src/comment/render.ts new file mode 100644 index 000000000..edf421178 --- /dev/null +++ b/packages/artalk/src/comment/render.ts @@ -0,0 +1,425 @@ +import '../style/comment.less' + +import * as Utils from '../lib/utils' +import * as Ui from '../lib/ui' +import ActionBtn from '../components/action-btn' +import CommentHTML from './comment.html?raw' +import Comment from './comment' + +export default class CommentRender { + private comment: Comment + + private get ctx() { return this.comment.ctx } + private get data() { return this.comment.getData() } + private get cConf() { return this.comment.getConf() } + + public $el!: HTMLElement + public $main!: HTMLElement + public $header!: HTMLElement + public $headerNick!: HTMLElement + public $headerBadgeWrap!: HTMLElement + public $body!: HTMLElement + public $content!: HTMLElement + private $childrenWrap!: HTMLElement|null + public $actions!: HTMLElement + public voteBtnUp?: ActionBtn + public voteBtnDown?: ActionBtn + + public $replyTo?: HTMLElement // 回复评论内容 (平铺下显示) + public $replyAt?: HTMLElement // 回复 AT(层级嵌套下显示) + + public constructor(comment: Comment) { + this.comment = comment + } + + public render() { + this.$el = Utils.createElement(CommentHTML) + + this.$main = this.$el.querySelector('.atk-main')! + this.$header = this.$el.querySelector('.atk-header')! + this.$body = this.$el.querySelector('.atk-body')! + this.$content = this.$body.querySelector('.atk-content')! + this.$actions = this.$el.querySelector('.atk-actions')! + + this.$el.setAttribute('data-comment-id', `${this.data.id}`) + + this.renderAvatar() + this.renderHeader() + this.renderContent() + this.renderReplyAt() + this.renderReplyTo() + this.renderPending() + this.renderActionBtn() + + this.recoveryChildrenWrap() + + return this.$el + } + + /** 初始化 - 评论头像 */ + private renderAvatar() { + const $avatar = this.$el.querySelector('.atk-avatar')! + const $avatarImg = Utils.createElement('') + $avatarImg.src = this.comment.getGravatarURL() + if (this.data.link) { + const $avatarA = Utils.createElement('') + $avatarA.href = this.data.link + $avatarA.append($avatarImg) + $avatar.append($avatarA) + } else { + $avatar.append($avatarImg) + } + } + + /** 初始化 - 评论信息 */ + private renderHeader() { + this.$headerNick = this.$el.querySelector('.atk-nick')! + + if (this.data.link) { + const $nickA = Utils.createElement('') + $nickA.innerText = this.data.nick + $nickA.href = this.data.link + this.$headerNick.append($nickA) + } else { + this.$headerNick.innerText = this.data.nick + } + + this.$headerBadgeWrap = this.$el.querySelector('.atk-badge-wrap')! + this.$headerBadgeWrap.innerHTML = '' + + const badgeText = this.data.badge_name + const badgeColor = this.data.badge_color + if (badgeText) { + const $badge = Utils.createElement(``) + $badge.innerText = badgeText.replace('管理员', this.ctx.$t('admin')) // i18n patch + $badge.style.backgroundColor = badgeColor || '' + this.$headerBadgeWrap.append($badge) + } + + if (this.data.is_pinned) { + const $pinnedBadge = Utils.createElement(`${this.ctx.$t('pin')}`) // 置顶徽章 + this.$headerBadgeWrap.append($pinnedBadge) + } + + const $date = this.$el.querySelector('.atk-date')! + $date.innerText = this.comment.getDateFormatted() + $date.setAttribute('data-atk-comment-date', String(+new Date(this.data.date))) + + if (this.ctx.conf.uaBadge) { + let $uaWrap = this.$header.querySelector('atk-ua-wrap') + if (!$uaWrap) { + $uaWrap = Utils.createElement(``) + this.$header.append($uaWrap) + } + + $uaWrap.innerHTML = '' + const $uaBrowser = Utils.createElement(``) + const $usOS = Utils.createElement(``) + $uaBrowser.innerText = this.comment.getUserUaBrowser() + $usOS.innerText = this.comment.getUserUaOS() + $uaWrap.append($uaBrowser) + $uaWrap.append($usOS) + } + } + + /** 初始化 - 评论内容 */ + private renderContent() { + if (!this.data.is_collapsed) { + this.$content.innerHTML = this.comment.getContentMarked() + this.$content.classList.remove('atk-hide', 'atk-collapsed') + return + } + + // 内容 & 折叠 + this.$content.classList.add('atk-hide', 'atk-type-collapsed') + const collapsedInfoEl = Utils.createElement(` +
+ ${this.ctx.$t('collapsedMsg')} + ${this.ctx.$t('expand')} +
`) + this.$body.insertAdjacentElement('beforeend', collapsedInfoEl) + + const contentShowBtn = collapsedInfoEl.querySelector('.atk-show-btn')! + contentShowBtn.addEventListener('click', (e) => { + e.stopPropagation() // 防止穿透 + + if (this.$content.classList.contains('atk-hide')) { + this.$content.innerHTML = this.comment.getContentMarked() + this.$content.classList.remove('atk-hide') + Ui.playFadeInAnim(this.$content) + contentShowBtn.innerHTML = this.ctx.$t('collapse') + } else { + this.$content.innerHTML = '' + this.$content.classList.add('atk-hide') + contentShowBtn.innerHTML = this.ctx.$t('expand') + } + }) + } + + /** 初始化 - 层级嵌套模式显示 At */ + private renderReplyAt() { + if (this.cConf.isFlatMode || this.data.rid === 0) return // not 平铺模式 或 根评论 + if (!this.cConf.replyTo) return + + this.$replyAt = Utils.createElement(``) + this.$replyAt.querySelector('.atk-nick')!.innerText = `${this.cConf.replyTo.nick}` + this.$replyAt.onclick = () => { this.comment.getActions().goToReplyComment() } + + this.$headerBadgeWrap.insertAdjacentElement('afterend', this.$replyAt) + } + + /** 初始化 - 回复的对象 */ + private renderReplyTo() { + if (!this.cConf.isFlatMode) return // 仅平铺模式显示 + if (!this.cConf.replyTo) return + + this.$replyTo = Utils.createElement(` +
+
${this.ctx.$t('reply')} :
+
+
`) + const $nick = this.$replyTo.querySelector('.atk-nick')! + $nick.innerText = `@${this.cConf.replyTo.nick}` + $nick.onclick = () => { this.comment.getActions().goToReplyComment() } + let replyContent = Utils.marked(this.ctx, this.cConf.replyTo.content) + if (this.cConf.replyTo.is_collapsed) replyContent = `[${this.ctx.$t('collapsed')}]` + this.$replyTo.querySelector('.atk-content')!.innerHTML = replyContent + this.$body.prepend(this.$replyTo) + } + + /** 初始化 - 待审核状态 */ + private renderPending() { + if (!this.data.is_pending) return + + const pendingEl = Utils.createElement(`
${this.ctx.$t('pendingMsg')}
`) + this.$body.prepend(pendingEl) + } + + /** 初始化 - 评论操作按钮 */ + private renderActionBtn() { + // 投票功能 + if (this.ctx.conf.vote) { + // 赞同按钮 + this.voteBtnUp = new ActionBtn(this.ctx, () => `${this.ctx.$t('voteUp')} (${this.data.vote_up || 0})`).appendTo(this.$actions) + this.voteBtnUp.setClick(() => { + this.comment.getActions().vote('up') + }) + + // 反对按钮 + if (this.ctx.conf.voteDown) { + this.voteBtnDown = new ActionBtn(this.ctx, () => `${this.ctx.$t('voteDown')} (${this.data.vote_down || 0})`).appendTo(this.$actions) + this.voteBtnDown.setClick(() => { + this.comment.getActions().vote('down') + }) + } + } + + // 绑定回复按钮事件 + if (this.data.is_allow_reply) { + const replyBtn = Utils.createElement(`${this.ctx.$t('reply')}`) + this.$actions.append(replyBtn) + replyBtn.addEventListener('click', (e) => { + e.stopPropagation() // 防止穿透 + if (!this.cConf.onReplyBtnClick) { + this.ctx.trigger('editor-reply', {data: this.data, $el: this.$el}) + } else { + this.cConf.onReplyBtnClick() + } + }) + } + + // 绑定折叠按钮事件 + const collapseBtn = new ActionBtn(this.ctx, { + text: () => (this.data.is_collapsed ? this.ctx.$t('expand') : this.ctx.$t('collapse')), + adminOnly: true + }) + collapseBtn.appendTo(this.$actions) + collapseBtn.setClick(() => { + this.comment.getActions().adminEdit('collapsed', collapseBtn) + }) + + // 绑定待审核按钮事件 + const pendingBtn = new ActionBtn(this.ctx, { + text: () => (this.data.is_pending ? this.ctx.$t('pending') : this.ctx.$t('approved')), + adminOnly: true + }) + pendingBtn.appendTo(this.$actions) + pendingBtn.setClick(() => { + this.comment.getActions().adminEdit('pending', pendingBtn) + }) + + // 绑定删除按钮事件 + const delBtn = new ActionBtn(this.ctx, { + text: this.ctx.$t('delete'), + confirm: true, + confirmText: this.ctx.$t('deleteConfirm'), + adminOnly: true, + }) + delBtn.appendTo(this.$actions) + delBtn.setClick(() => { + this.comment.getActions().adminDelete(delBtn) + }) + + // 绑定置顶按钮事件 + const pinnedBtn = new ActionBtn(this.ctx, { + text: () => (this.data.is_pinned ? this.ctx.$t('unpin') : this.ctx.$t('pin')), + adminOnly: true + }) + pinnedBtn.appendTo(this.$actions) + pinnedBtn.setClick(() => { + this.comment.getActions().adminEdit('pinned', pendingBtn) + }) + } + + /** 内容限高检测 */ + public checkHeightLimit() { + this.checkHeightLimitArea('content') // 评论内容限高 + this.checkHeightLimitArea('children') // 子评论部分限高(嵌套模式) + } + + /** 目标内容限高检测 */ + public checkHeightLimitArea(area: 'children'|'content') { + // 参数准备 + const childrenMaxH = this.ctx.conf.heightLimit.children + const contentMaxH = this.ctx.conf.heightLimit.content + + if (area === 'children' && !childrenMaxH) return + if (area === 'content' && !contentMaxH) return + + // 限高 + let maxHeight: number + if (area === 'children') maxHeight = childrenMaxH! + if (area === 'content') maxHeight = contentMaxH! + + // 检测指定元素 + const checkEl = ($el?: HTMLElement|null) => { + if (!$el) return + + // 是否超过高度 + if (Utils.getHeight($el) > maxHeight) { + this.heightLimitAdd($el, maxHeight) + } + } + + // 执行限高检测 + if (area === 'children') { + checkEl(this.$childrenWrap) + } else if (area === 'content') { + checkEl(this.$content) + checkEl(this.$replyTo) + + // 若有图片 · 图片加载完后再检测一次 + Utils.onImagesLoaded(this.$content, () => { + checkEl(this.$content) + }) + if (this.$replyTo) { + Utils.onImagesLoaded(this.$replyTo, () => { + checkEl(this.$replyTo) + }) + } + } + } + + /** 移除限高 */ + private heightLimitRemove($el: HTMLElement) { + if (!$el) return + if (!$el.classList.contains('atk-height-limit')) return + + $el.classList.remove('atk-height-limit') + Array.from($el.children).forEach((e) => { + if (e.classList.contains('atk-height-limit-btn')) e.remove() + }) + $el.style.height = '' + $el.style.overflow = '' + } + + /** 子评论区域移除限高 */ + public heightLimitRemoveForChildren() { + if (!this.$childrenWrap) return + this.heightLimitRemove(this.$childrenWrap) + } + + /** 内容限高区域新增 */ + private heightLimitAdd($el: HTMLElement, maxHeight: number) { + if (!$el) return + if ($el.classList.contains('atk-height-limit')) return + + $el.classList.add('atk-height-limit') + $el.style.height = `${maxHeight}px` + $el.style.overflow = 'hidden' + const $hideMoreOpenBtn = Utils.createElement(`
${this.ctx.$t('readMore')}`) + $hideMoreOpenBtn.onclick = (e) => { + e.stopPropagation() + this.heightLimitRemove($el) + + // 子评论数等于 1,直接取消限高 + const children = this.comment.getChildren() + if (children.length === 1) children[0].getRender().heightLimitRemove(children[0].getRender().$content) + } + $el.append($hideMoreOpenBtn) + } + + /** 渐出动画 */ + playFadeAnim() { + Ui.playFadeInAnim(this.comment.getRender().$el) + } + + /** 渐出动画 · 评论内容区域 */ + playFadeAnimForBody() { + Ui.playFadeInAnim(this.comment.getRender().$body) + } + + /** 获取子评论 Wrap */ + public getChildrenWrap() { + return this.$childrenWrap + } + + /** 初始化子评论区域 Wrap */ + public renderChildrenWrap() { + if (!this.$childrenWrap) { + this.$childrenWrap = Utils.createElement('
') + this.$main.append(this.$childrenWrap) + } + + return this.$childrenWrap + } + + /** 恢复原有的子评论区域 Wrap */ + public recoveryChildrenWrap() { + if (this.$childrenWrap) { + this.$main.append(this.$childrenWrap) + } + } + + /** 设置已读 */ + public setUnread(val: boolean) { + if (val) this.$el.classList.add('atk-unread') + else this.$el.classList.remove('atk-unread') + } + + /** 设置为可点击的评论 */ + public setOpenable(val: boolean) { + if (val) this.$el.classList.add('atk-openable') + else this.$el.classList.remove('atk-openable') + } + + /** 设置点击评论打开置顶 URL */ + public setOpenURL(url: string) { + this.setOpenable(true) + this.$el.onclick = (evt) => { + evt.preventDefault() + window.open(url) + + if (this.cConf.openEvt) this.cConf.openEvt() + } + } + + /** 设置点击评论时的操作 */ + public setOpenAction(action: () => void) { + this.setOpenable(true) + this.$el.onclick = (evt) => { + evt.preventDefault() + action() + } + } +} diff --git a/packages/artalk/src/components/_component.ts.txt b/packages/artalk/src/components/_component.ts.txt deleted file mode 100644 index 251847c79..000000000 --- a/packages/artalk/src/components/_component.ts.txt +++ /dev/null @@ -1,10 +0,0 @@ -import Context from '@/context' -import Component from '@/lib/component' -import * as Utils from '@/lib/utils' -import * as Ui from '@/lib/ui' - -export default class Example extends Component { - constructor(ctx: Context) { - super(ctx) - } -} diff --git a/packages/artalk/src/components/action-btn.ts b/packages/artalk/src/components/action-btn.ts index 6d84b2c70..145666c48 100644 --- a/packages/artalk/src/components/action-btn.ts +++ b/packages/artalk/src/components/action-btn.ts @@ -1,4 +1,4 @@ -import Context from '../context' +import Context from '~/types/context' import * as Utils from '../lib/utils' interface ActionBtnConf { diff --git a/packages/artalk/src/components/comment.ts b/packages/artalk/src/components/comment.ts deleted file mode 100644 index eba1ede7b..000000000 --- a/packages/artalk/src/components/comment.ts +++ /dev/null @@ -1,607 +0,0 @@ -import '../style/comment.less' - -import { CommentData } from '~/types/artalk-data' -import Context from '../context' -import Component from '../lib/component' -import * as Utils from '../lib/utils' -import * as Ui from '../lib/ui' -import UADetect from '../lib/detect' -import CommentHTML from './html/comment.html?raw' -import Api from '../api' -import ActionBtn from './action-btn' - -export default class Comment extends Component { - public data: CommentData - - public $main!: HTMLElement - public $header!: HTMLElement - public $headerNick!: HTMLElement - public $headerBadge?: HTMLElement - public $body!: HTMLElement - public $content!: HTMLElement - public $children!: HTMLElement|null - public $actions!: HTMLElement - public voteBtnUp?: ActionBtn - public voteBtnDown?: ActionBtn - - public parent: Comment|null - public nestCurt: number - private nestMax: number // 最大嵌套层数 - private children: Comment[] = [] - - public flatMode: boolean = false - public replyTo?: CommentData // 回复对象 - - public $replyTo?: HTMLElement // 回复评论内容 (平铺下显示) - public $replyAt?: HTMLElement // 回复 AT(层级嵌套下显示) - - public afterRender?: () => void - - private unread = false - private openable = false - private openURL?: string - public openEvt?: () => void - - public onReplyBtnClick?: Function - - constructor(ctx: Context, data: CommentData) { - super(ctx) - - // 最大嵌套数 - this.nestMax = ctx.conf.nestMax || 3 - - this.data = { ...data } - this.data.date = this.data.date.replace(/-/g, '/') // 解决 Safari 日期解析 NaN 问题 - - this.parent = null - this.nestCurt = 1 // 现在已嵌套 n 层 - } - - /** 渲染 UI */ - public render() { - this.$el = Utils.createElement(CommentHTML) - this.$main = this.$el.querySelector('.atk-main')! - this.$header = this.$el.querySelector('.atk-header')! - this.$body = this.$el.querySelector('.atk-body')! - this.$content = this.$body.querySelector('.atk-content')! - this.$actions = this.$el.querySelector('.atk-actions')! - this.$children = null - - this.$el.setAttribute('data-comment-id', `${this.data.id}`) - - this.renderCheckUnread() - this.renderCheckClickable() - - this.renderAvatar() - this.renderHeader() - this.renderContent() - this.renderReplyAt() - this.renderReplyTo() - this.renderPending() - this.renderActionBtn() - - if (this.afterRender) this.afterRender() - - return this.$el - } - - //#region Renders - private renderCheckUnread() { - if (this.unread) this.$el.classList.add('atk-unread') - else this.$el.classList.remove('atk-unread') - } - - private renderCheckClickable() { - if (this.openable) { - this.$el.classList.add('atk-openable') - } else { - this.$el.classList.remove('atk-openable') - } - - this.$el.addEventListener('click', (evt) => { - if (this.openable && this.openURL) { - evt.preventDefault() - window.open(this.openURL) - } - if (this.openEvt) - this.openEvt() - }) - } - - private renderAvatar() { - const $avatar = this.$el.querySelector('.atk-avatar')! - const $avatarImg = Utils.createElement('') - $avatarImg.src = this.getGravatarUrl() - if (this.data.link) { - const $avatarA = Utils.createElement('') - $avatarA.href = this.data.link - $avatarA.append($avatarImg) - $avatar.append($avatarA) - } else { - $avatar.append($avatarImg) - } - } - - private renderHeader() { - this.$headerNick = this.$el.querySelector('.atk-nick')! - if (this.data.link) { - const $nickA = Utils.createElement('') - $nickA.innerText = this.data.nick - $nickA.href = this.data.link - this.$headerNick.append($nickA) - } else { - this.$headerNick.innerText = this.data.nick - } - - this.$headerBadge = this.$el.querySelector('.atk-badge')! - let badgeText = this.data.badge_name - if (badgeText) { - badgeText = badgeText.replace('管理员', this.$t('admin')) // i18n patch - this.$headerBadge.innerText = badgeText - if (this.data.badge_color) - this.$headerBadge.style.backgroundColor = this.data.badge_color - } else { - this.$headerBadge.remove() - this.$headerBadge = undefined - } - - if (this.data.is_pinned) { - const $pinnedBadge = Utils.createElement(`${this.$t('pin')}`) // 置顶徽章 - this.$headerNick.insertAdjacentElement('afterend', $pinnedBadge) - } - - const $date = this.$el.querySelector('.atk-date')! - $date.innerText = this.getDateFormatted() - $date.setAttribute('data-atk-comment-date', String(+new Date(this.data.date))) - - if (this.conf.uaBadge) { - const $uaWrap = Utils.createElement(``) - const $uaBrowser = Utils.createElement(``) - const $usOS = Utils.createElement(``) - $uaBrowser.innerText = this.getUserUaBrowser() - $usOS.innerText = this.getUserUaOS() - $uaWrap.append($uaBrowser) - $uaWrap.append($usOS) - this.$header.append($uaWrap) - } - } - - private renderContent() { - // 内容 & 折叠 - if (!this.data.is_collapsed) { - this.$content.innerHTML = this.getContentMarked() - return - } - - this.$content.classList.add('atk-hide', 'atk-type-collapsed') - const collapsedInfoEl = Utils.createElement(` -
- ${this.$t('collapsedMsg')} - ${this.$t('expand')} -
`) - this.$body.insertAdjacentElement('beforeend', collapsedInfoEl) - - const contentShowBtn = collapsedInfoEl.querySelector('.atk-show-btn')! - contentShowBtn.addEventListener('click', (e) => { - e.stopPropagation() // 防止穿透 - - if (this.$content.classList.contains('atk-hide')) { - this.$content.innerHTML = this.getContentMarked() - this.$content.classList.remove('atk-hide') - Ui.playFadeInAnim(this.$content) - contentShowBtn.innerHTML = this.$t('collapse') - } else { - this.$content.innerHTML = '' - this.$content.classList.add('atk-hide') - contentShowBtn.innerHTML = this.$t('expand') - } - }) - } - - // 层级嵌套模式显示 At - private renderReplyAt() { - if (this.flatMode || this.data.rid === 0) return // not 平铺模式 或 根评论 - if (this.$replyAt) return // not 关闭显示 或 已经显示 - if (!this.replyTo) return - - this.$replyAt = Utils.createElement(``) - this.$replyAt.querySelector('.atk-nick')!.innerText = `${this.replyTo.nick}` - this.$replyAt.onclick = () => { this.goToReplyComment() } - - const $after = this.$headerBadge || this.$headerNick - $after.insertAdjacentElement('afterend', this.$replyAt) - } - - // 回复的对象 - private renderReplyTo() { - if (!this.flatMode) return // 仅平铺模式显示 - if (!this.replyTo) return - - this.$replyTo = Utils.createElement(` -
-
${this.$t('reply')} :
-
-
`) - const $nick = this.$replyTo.querySelector('.atk-nick')! - $nick.innerText = `@${this.replyTo.nick}` - $nick.onclick = () => { this.goToReplyComment() } - let replyContent = Utils.marked(this.ctx, this.replyTo.content) - if (this.replyTo.is_collapsed) replyContent = `[${this.$t('collapsed')}]` - this.$replyTo.querySelector('.atk-content')!.innerHTML = replyContent - this.$body.prepend(this.$replyTo) - } - - public goToReplyComment() { - const origHash = window.location.hash - const modifyHash = `#atk-comment-${this.data.rid}` - - window.location.hash = modifyHash - if (modifyHash === origHash) window.dispatchEvent(new Event('hashchange')) // 强制触发事件 - } - - // 待审核状态 - private renderPending() { - if (!this.data.is_pending) return - - const pendingEl = Utils.createElement(`
${this.$t('pendingMsg')}
`) - this.$body.prepend(pendingEl) - } - - /** 初始化评论操作按钮 */ - private renderActionBtn() { - // 投票功能 - if (this.ctx.conf.vote) { - // 赞同按钮 - this.voteBtnUp = new ActionBtn(this.ctx, () => `${this.$t('voteUp')} (${this.data.vote_up || 0})`).appendTo(this.$actions) - this.voteBtnUp.setClick(() => { - this.vote('up') - }) - - // 反对按钮 - if (this.ctx.conf.voteDown) { - this.voteBtnDown = new ActionBtn(this.ctx, () => `${this.$t('voteDown')} (${this.data.vote_down || 0})`).appendTo(this.$actions) - this.voteBtnDown.setClick(() => { - this.vote('down') - }) - } - } - - // 绑定回复按钮事件 - if (this.data.is_allow_reply) { - const replyBtn = Utils.createElement(`${this.$t('reply')}`) - this.$actions.append(replyBtn) - replyBtn.addEventListener('click', (e) => { - e.stopPropagation() // 防止穿透 - if (!this.onReplyBtnClick) { - this.ctx.trigger('editor-reply', {data: this.data, $el: this.$el}) - } else { - this.onReplyBtnClick() - } - }) - } - - // 绑定折叠按钮事件 - const collapseBtn = new ActionBtn(this.ctx, { - text: () => (this.data.is_collapsed ? this.$t('expand') : this.$t('collapse')), - adminOnly: true - }) - collapseBtn.appendTo(this.$actions) - collapseBtn.setClick(() => { - this.adminEdit('collapsed', collapseBtn) - }) - - // 绑定待审核按钮事件 - const pendingBtn = new ActionBtn(this.ctx, { - text: () => (this.data.is_pending ? this.$t('pending') : this.$t('approved')), - adminOnly: true - }) - pendingBtn.appendTo(this.$actions) - pendingBtn.setClick(() => { - this.adminEdit('pending', pendingBtn) - }) - - // 绑定删除按钮事件 - const delBtn = new ActionBtn(this.ctx, { - text: this.$t('delete'), - confirm: true, - confirmText: this.$t('deleteConfirm'), - adminOnly: true, - }) - delBtn.appendTo(this.$actions) - delBtn.setClick(() => { - this.adminDelete(delBtn) - }) - - // 绑定置顶按钮事件 - const pinnedBtn = new ActionBtn(this.ctx, { - text: () => (this.data.is_pinned ? this.$t('unpin') : this.$t('pin')), - adminOnly: true - }) - pinnedBtn.appendTo(this.$actions) - pinnedBtn.setClick(() => { - this.adminEdit('pinned', pendingBtn) - }) - } - //#endregion - - /** 刷新评论 UI */ - public refreshUI() { - const originalEl = this.$el - const newEl = this.render() - originalEl.replaceWith(newEl) // 替换 document 中的的 elem - this.playFadeInAnim() - - // 重建子评论元素 - this.eachComment(this.children, (child) => { - child.parent?.getChildrenEl().appendChild(child.render()) - child.playFadeInAnim() - }) - - this.ctx.trigger('comments-loaded') - } - - /** 遍历评论 */ - private eachComment(commentList: Comment[], action: (comment: Comment, levelList: Comment[]) => boolean|void) { - if (commentList.length === 0) return - commentList.every((item) => { - if (action(item, commentList) === false) return false - this.eachComment(item.getChildren(), action) - return true - }) - } - - getIsRoot() { - return this.data.rid === 0 - } - - getChildren() { - return this.children - } - - putChild(childC: Comment, insertMode: 'append'|'prepend' = 'append') { - childC.parent = this - childC.nestCurt = this.nestCurt + 1 // 嵌套层数 +1 - this.children.push(childC) - - const $children = this.getChildrenEl() - if (insertMode === 'append') $children.append(childC.getEl()) - else if (insertMode === 'prepend') $children.prepend(childC.getEl()) - - childC.playFadeInAnim() - - // 内容限高 - childC.checkHeightLimitArea('content') - } - - getChildrenEl(): HTMLElement { - if (!this.$children) { - // console.log(this.nestCurt) - if (this.nestCurt < this.nestMax) { - this.$children = Utils.createElement('
') - this.$main.append(this.$children) - } else if (this.parent) { - this.$children = this.parent.getChildrenEl() - } - } - - return this.$children! - } - - getParent() { - return this.parent - } - - getParents() { - const parents: Comment[] = [] - const once = (c: Comment) => { - if (c.parent) { - parents.push(c.parent) - once(c.parent) - } - } - - once(this) - return parents - } - - getEl() { - return this.$el - } - - getData() { - return this.data - } - - getGravatarUrl() { - return Utils.getGravatarURL(this.ctx, this.data.email_encrypted) - } - - getContentMarked() { - return Utils.marked(this.ctx, this.data.content) - } - - getDateFormatted() { - return Utils.timeAgo(new Date(this.data.date), this.ctx) - } - - getUserUaBrowser() { - const info = UADetect(this.data.ua) - return `${info.browser} ${info.version}` - } - - getUserUaOS() { - const info = UADetect(this.data.ua) - return `${info.os} ${info.osVersion}` - } - - /** 渐出动画 */ - playFadeInAnim() { - Ui.playFadeInAnim(this.$el) - } - - /** 投票操作 */ - vote(type: 'up'|'down') { - const actionBtn = type === 'up' ? this.voteBtnUp : this.voteBtnDown - - new Api(this.ctx).vote(this.data.id, `comment_${type}`) - .then((v) => { - this.data.vote_up = v.up - this.data.vote_down = v.down - this.voteBtnUp?.updateText() - this.voteBtnDown?.updateText() - }) - .catch((err) => { - actionBtn?.setError(this.$t('voteFail')) - console.log(err) - }) - } - - /** 管理员 - 评论状态修改 */ - adminEdit(type: 'collapsed'|'pending'|'pinned', btnElem: ActionBtn) { - if (btnElem.isLoading) return // 若正在修改中 - - btnElem.setLoading(true, `${this.$t('editing')}...`) - - // 克隆并修改当前数据 - const modify = { ...this.data } - if (type === 'collapsed') { - modify.is_collapsed = !modify.is_collapsed - } else if (type === 'pending') { - modify.is_pending = !modify.is_pending - } else if (type === 'pinned') { - modify.is_pinned = !modify.is_pinned - } - - new Api(this.ctx).commentEdit(modify).then((comment) => { - btnElem.setLoading(false) - - // 刷新当前 Comment UI - this.data = comment - this.refreshUI() - Ui.playFadeInAnim(this.$body) - - // 刷新 List UI - this.ctx.trigger('list-refresh-ui') - }).catch((err) => { - console.error(err) - btnElem.setError(this.$t('editFail')) - }) - } - - public onDelete?: (comment: Comment) => void - - /** 管理员 - 评论删除 */ - adminDelete(btnElem: ActionBtn) { - if (btnElem.isLoading) return // 若正在删除中 - - btnElem.setLoading(true, `${this.$t('deleting')}...`) - new Api(this.ctx).commentDel(this.data.id, this.data.site_name) - .then(() => { - btnElem.setLoading(false) - if (this.onDelete) this.onDelete(this) - }) - .catch((e) => { - console.error(e) - btnElem.setError(this.$t('deleteFail')) - }) - } - - public setUnread(val: boolean) { - this.unread = val - if (this.unread) this.$el.classList.add('atk-unread') - else this.$el.classList.remove('atk-unread') - } - - public setOpenURL(url: string) { - if (!url) { - this.openable = false - this.$el.classList.remove('atk-openable') - } - - this.openable = true - this.openURL = url - this.$el.classList.add('atk-openable') - } - - /** 内容限高检测 */ - checkHeightLimit() { - this.checkHeightLimitArea('content') // 评论内容限高 - this.checkHeightLimitArea('children') // 子评论部分限高(嵌套模式) - } - - /** 目标内容限高检测 */ - checkHeightLimitArea(area: 'children'|'content') { - // 参数准备 - const childrenMaxH = this.ctx.conf.heightLimit?.children - const contentMaxH = this.ctx.conf.heightLimit?.content - - if (area === 'children' && !childrenMaxH) return - if (area === 'content' && !contentMaxH) return - - // 限高 - let maxHeight: number - if (area === 'children') maxHeight = childrenMaxH! - if (area === 'content') maxHeight = contentMaxH! - - // 检测指定元素 - const checkEl = ($el?: HTMLElement|null) => { - if (!$el) return - - // 是否超过高度 - if (Utils.getHeight($el) > maxHeight) { - this.heightLimitAdd($el, maxHeight) - } - } - - // 执行限高检测 - if (area === 'children') { - checkEl(this.$children) - } else if (area === 'content') { - checkEl(this.$content) - checkEl(this.$replyTo) - - // 若有图片 · 图片加载完后再检测一次 - Utils.onImagesLoaded(this.$content, () => { - checkEl(this.$content) - }) - if (this.$replyTo) { - Utils.onImagesLoaded(this.$replyTo, () => { - checkEl(this.$replyTo) - }) - } - } - } - - // 操作 · 取消限高 - heightLimitRemove($el: HTMLElement) { - if (!$el) return - if (!$el.classList.contains('atk-height-limit')) return - - $el.classList.remove('atk-height-limit') - Array.from($el.children).forEach((e) => { - if (e.classList.contains('atk-height-limit-btn')) e.remove() - }) - $el.style.height = '' - $el.style.overflow = '' - } - - // 操作 · 内容限高 - heightLimitAdd($el: HTMLElement, maxHeight: number) { - if (!$el) return - if ($el.classList.contains('atk-height-limit')) return - - $el.classList.add('atk-height-limit') - $el.style.height = `${maxHeight}px` - $el.style.overflow = 'hidden' - const $hideMoreOpenBtn = Utils.createElement(`
${this.$t('readMore')}`) - $hideMoreOpenBtn.onclick = (e) => { - e.stopPropagation() - this.heightLimitRemove($el) - - // 子评论数等于 1,直接取消限高 - const children = this.getChildren() - if (children.length === 1) children[0].heightLimitRemove(children[0].$content) - } - $el.append($hideMoreOpenBtn) - } -} diff --git a/packages/artalk/src/components/dialog.ts b/packages/artalk/src/components/dialog.ts index d748ea3fd..a54544d8e 100644 --- a/packages/artalk/src/components/dialog.ts +++ b/packages/artalk/src/components/dialog.ts @@ -1,4 +1,4 @@ -import Context from '../context' +import Context from '~/types/context' import * as Utils from '../lib/utils' type BtnClickHandler = (btnEl: HTMLElement, dialog: Dialog) => boolean|void diff --git a/packages/artalk/src/components/editor-plugs/_example-plug.ts.txt b/packages/artalk/src/components/editor-plugs/_example-plug.ts.txt deleted file mode 100644 index e6dc88456..000000000 --- a/packages/artalk/src/components/editor-plugs/_example-plug.ts.txt +++ /dev/null @@ -1,29 +0,0 @@ -import './_PreviewPlug/PreviewPlug.less' -import Editor from '../Editor' -import ArtalkContext from '~/src/ArtalkContext' -import Utils from '~/src/utils' - -export default class PreviewPlug extends ArtalkContext { - public elem: HTMLElement - - constructor (public editor: Editor) { - super(editor.artalk) - - this.initElem() - } - - initElem () { - this.elem = Utils.createElement('
') - } - - static Name = 'example' - static BtnHTML = '栗子' - - getEl () { - return this.elem - } - - onShow () {} - - onHide () {} -} diff --git a/packages/artalk/src/components/editor-plugs/editor-plug.ts b/packages/artalk/src/components/editor-plugs/editor-plug.ts deleted file mode 100644 index 3315f9f10..000000000 --- a/packages/artalk/src/components/editor-plugs/editor-plug.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Context from "~/src/context" -import Editor from "../editor" - -export default abstract class EditorPlug { - protected editor: Editor - protected ctx: Context - public abstract $el: HTMLElement - - constructor (editor: Editor) { - this.editor = editor - this.ctx = editor.ctx - } - - public static Name: string - public static BtnHTML: string - - public abstract getEl(): HTMLElement - public abstract onShow(): void - public abstract onHide(): void -} diff --git a/packages/artalk/src/components/editor-plugs/preview-plug.ts b/packages/artalk/src/components/editor-plugs/preview-plug.ts deleted file mode 100644 index 91ea36cd4..000000000 --- a/packages/artalk/src/components/editor-plugs/preview-plug.ts +++ /dev/null @@ -1,48 +0,0 @@ -import './preview-plug.less' - -import * as Utils from '~/src/lib/utils' -import Editor from '../editor' -import EditorPlug from './editor-plug' - -export default class PreviewPlug extends EditorPlug { - $el!: HTMLElement - binded: boolean = false - - constructor (editor: Editor) { - super(editor) - - this.initEl() - } - - initEl () { - this.$el = Utils.createElement('
') - this.binded = false - } - - static Name = 'preview' - static BtnHTML = '预览 ' - - getEl () { - return this.$el - } - - onShow () { - this.updateContent() - if (!this.binded) { - const event = () => { - this.updateContent() - } - this.editor.$textarea.addEventListener('input', event) - this.editor.$textarea.addEventListener('change', event) - this.binded = true - } - } - - onHide () {} - - updateContent () { - if (this.$el.style.display !== 'none') { - this.$el.innerHTML = this.editor.getContentMarked() - } - } -} diff --git a/packages/artalk/src/components/editor.ts b/packages/artalk/src/components/editor.ts deleted file mode 100644 index 4df933448..000000000 --- a/packages/artalk/src/components/editor.ts +++ /dev/null @@ -1,615 +0,0 @@ -import '../style/editor.less' - -import { CommentData } from '~/types/artalk-data' -import Context from '../context' -import Component from '../lib/component' -import * as Utils from '../lib/utils' -import * as Ui from '../lib/ui' -import EditorHTML from './html/editor.html?raw' - -import EmoticonsPlug from './editor-plugs/emoticons-plug' -import PreviewPlug from './editor-plugs/preview-plug' -import Api from '../api' - -export default class Editor extends Component { - private readonly LOADABLE_PLUG_LIST = [EmoticonsPlug, PreviewPlug] - public plugList: { [name: string]: any } = {} - - public $header: HTMLElement - public $textareaWrap: HTMLElement - public $textarea: HTMLTextAreaElement - public $plugWrap: HTMLElement - public $bottom: HTMLElement - public $plugBtnWrap: HTMLElement - public $imgUploadBtn?: HTMLElement - public $imgUploadInput?: HTMLInputElement - public $submitBtn: HTMLButtonElement - public $notifyWrap: HTMLElement - - private replyComment: CommentData|null = null - private $sendReply: HTMLElement|null = null - - private isTraveling = false - - private get user () { - return this.ctx.user - } - - constructor (ctx: Context) { - super(ctx) - - this.$el = Utils.createElement(EditorHTML) - - this.$header = this.$el.querySelector('.atk-header')! - this.$textareaWrap = this.$el.querySelector('.atk-textarea-wrap')! - this.$textarea = this.$el.querySelector('.atk-textarea')! - this.$plugWrap = this.$el.querySelector('.atk-plug-wrap')! - this.$bottom = this.$el.querySelector('.atk-bottom')! - this.$plugBtnWrap = this.$el.querySelector('.atk-plug-btn-wrap')! - this.$submitBtn = this.$el.querySelector('.atk-send-btn')! - this.$notifyWrap = this.$el.querySelector('.atk-notify-wrap')! - - this.initLocalStorage() - this.initHeader() - this.initTextarea() - this.initEditorPlug() - this.initBottomPart() - - // 监听事件 - this.ctx.on('editor-open', () => (this.open())) - this.ctx.on('editor-close', () => (this.close())) - this.ctx.on('editor-reply', (p) => (this.setReply(p.data, p.$el, p.scroll))) - this.ctx.on('editor-reply-cancel', () => (this.cancelReply())) - this.ctx.on('editor-show-loading', () => (Ui.showLoading(this.$el))) - this.ctx.on('editor-hide-loading', () => (Ui.hideLoading(this.$el))) - this.ctx.on('editor-notify', (f) => (this.showNotify(f.msg, f.type))) - this.ctx.on('editor-travel', ($el) => (this.travel($el))) - this.ctx.on('editor-travel-back', () => (this.travelBack())) - this.ctx.on('conf-updated', () => (this.refreshUploadBtn())) - } - - initLocalStorage () { - const localContent = window.localStorage.getItem('ArtalkContent') || '' - if (localContent.trim() !== '') { - this.showNotify(this.$t('restoredMsg'), 'i') - this.setContent(localContent) - } - this.$textarea.addEventListener('input', () => { - this.saveContent() - }) - } - - initHeader () { - Object.keys(this.user.data).forEach((field) => { - const inputEl = this.getInputEl(field) - if (inputEl && inputEl instanceof HTMLInputElement) { - inputEl.value = this.user.data[field] || '' - // 绑定事件 - inputEl.addEventListener('input', () => this.onHeaderInput(field, inputEl)) - } - }) - - // Link URL 自动补全协议 - const $linkInput = this.getInputEl('link') - if ($linkInput) { - $linkInput.addEventListener('change', () => { - const link = $linkInput.value.trim() - if (!!link && !/^(http|https):\/\//.test(link)) { - $linkInput.value = `https://${link}` - this.user.data.link = $linkInput.value - this.saveUser() - } - }) - } - - // i18n patch - [['nick', '昵称'], ['email', '邮箱'], ['link', '网址']].forEach((entry) => { - const $input = this.getInputEl(entry[0])! - $input.placeholder = $input.placeholder.replace(entry[1], this.$t(entry[0] as any)) - }) - } - - getInputEl (field: 'nick'|'email'|'link'|string) { - const inputEl = this.$header.querySelector(`[name="${field}"]`) - return inputEl - } - - queryUserInfo = { - timeout: null, - abortFunc: <(() => void)|null>null - } - - /** header 输入框内容变化事件 */ - onHeaderInput(field: string, inputEl: HTMLInputElement) { - this.user.data[field] = inputEl.value.trim() - - // 若修改的是 nick or email - if (field === 'nick' || field === 'email') { - this.user.data.token = '' // 清除 token 登陆状态 - this.user.data.isAdmin = false - - // 获取用户信息 - if (this.queryUserInfo.timeout !== null) window.clearTimeout(this.queryUserInfo.timeout) // 清除待发出的请求 - if (this.queryUserInfo.abortFunc !== null) this.queryUserInfo.abortFunc() // 之前发出未完成的请求立刻中止 - - this.queryUserInfo.timeout = window.setTimeout(() => { - this.queryUserInfo.timeout = null // 清理 - - const {req, abort} = new Api(this.ctx).userGet( - this.user.data.nick, this.user.data.email - ) - this.queryUserInfo.abortFunc = abort - req.then(data => { - if (!data.is_login) { - this.user.data.token = '' - this.user.data.isAdmin = false - } - - // 未读消息更新 - this.ctx.trigger('unread-update', { notifies: data.unread, }) - - // 若用户为管理员,执行登陆操作 - if (this.user.checkHasBasicUserInfo() && !data.is_login && data.user && data.user.is_admin) { - this.showLoginDialog() - } - - // 自动填入 link - if (data.user && data.user.link) { - this.user.data.link = data.user.link - this.getInputEl('link')!.value = data.user.link - } - }) - .catch(() => {}) - .finally(() => { - this.queryUserInfo.abortFunc = null // 清理 - }) - }, 400) // 延迟执行,减少请求次数 - } - - this.saveUser() - } - - showLoginDialog () { - this.ctx.trigger('checker-admin', { - onSuccess: () => { - } - }) - } - - saveUser () { - this.user.save() - this.ctx.trigger('user-changed', this.ctx.user.data) - } - - saveContent () { - window.localStorage.setItem('ArtalkContent', this.getContentOriginal().trim()) - } - - initTextarea () { - // 占位符 - this.$textarea.placeholder = this.ctx.conf.placeholder || this.$t('placeholder') - - // 修复按下 Tab 输入的内容 - this.$textarea.addEventListener('keydown', (e) => { - const keyCode = e.keyCode || e.which - - if (keyCode === 9) { - e.preventDefault() - this.insertContent('\t') - } - }) - - // 输入框高度随内容而变化 - this.$textarea.addEventListener('input', (evt) => { - this.adjustTextareaHeight() - }) - } - - adjustTextareaHeight () { - const diff = this.$textarea.offsetHeight - this.$textarea.clientHeight - this.$textarea.style.height = '0px' // it's a magic. 若不加此行,内容减少,高度回不去 - this.$textarea.style.height = `${this.$textarea.scrollHeight + diff}px` - } - - openedPlugName: string|null = null - - initEditorPlug () { - this.plugList = {} - this.$plugWrap.innerHTML = '' - this.$plugWrap.style.display = 'none' - this.openedPlugName = null - this.$plugBtnWrap.innerHTML = '' - - // 初始化 plug 按钮 - this.LOADABLE_PLUG_LIST.forEach((PlugObj) => { - if (PlugObj.Name === 'emoticons' && !this.conf.emoticons) return - - // 切换按钮 - let btnHtml = PlugObj.BtnHTML - btnHtml = btnHtml.replace('表情', this.$t('emoticon')).replace('预览', this.$t('preview')) - const btnElem = Utils.createElement(`${btnHtml}`) - this.$plugBtnWrap.appendChild(btnElem) - - btnElem.addEventListener('click', () => { - let plug = this.plugList[PlugObj.Name] - if (!plug) { - plug = new PlugObj(this) - this.plugList[PlugObj.Name] = plug - } - - this.$plugBtnWrap.querySelectorAll('.active').forEach(item => item.classList.remove('active')) - - // 若点击已打开的,则收起 - if (PlugObj.Name === this.openedPlugName) { - plug.onHide() - this.$plugWrap.style.display = 'none' - this.openedPlugName = null - return - } - - if (this.$plugWrap.querySelector(`[data-plug-name="${PlugObj.Name}"]`) === null) { - // 需要初始化 - const plugEl = plug.getEl() - plugEl.setAttribute('data-plug-name', PlugObj.Name) - plugEl.style.display = 'none' - this.$plugWrap.appendChild(plugEl) - } - - (Array.from(this.$plugWrap.children) as HTMLElement[]).forEach((plugItemEl: HTMLElement) => { - const plugItemName = plugItemEl.getAttribute('data-plug-name')! - if (plugItemName === PlugObj.Name) { - plugItemEl.style.display = '' - this.plugList[plugItemName].onShow() - } else { - plugItemEl.style.display = 'none' - this.plugList[plugItemName].onHide() - } - }) - - this.$plugWrap.style.display = '' - this.openedPlugName = PlugObj.Name - - btnElem.classList.add('active') - }) - }) - - // 表情包插件预加载 - if (this.conf.emoticons) { - const emoPlug = new EmoticonsPlug(this) - this.plugList[EmoticonsPlug.Name] = emoPlug - - window.setTimeout(() => { - emoPlug.loadEmoticonsData() - }, 1000) // 延迟 1s 加载 - } - - this.initImgUpload() - } - - /** 关闭编辑器插件 */ - closePlug () { - this.$plugWrap.innerHTML = '' - this.$plugWrap.style.display = 'none' - this.openedPlugName = null - } - - /** 允许的图片格式 */ - allowImgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'] - - /** 初始化图片上传功能 */ - initImgUpload() { - this.$imgUploadBtn = Utils.createElement(`${this.$t('image')}`) - this.$plugBtnWrap.querySelector('[data-plug-name="preview"]')!.before(this.$imgUploadBtn) // 显示在预览图标之前 - - this.$imgUploadInput = document.createElement('input') - this.$imgUploadInput.type = 'file' - this.$imgUploadInput.style.display = 'none' - this.$imgUploadInput.accept = this.allowImgExts.map(o => `.${o}`).join(',') - this.$imgUploadBtn.after(this.$imgUploadInput) - - // 按钮点击 - this.$imgUploadBtn.onclick = () => { - // 选择图片 - const $input = this.$imgUploadInput! - $input.onchange = () => { - (async () => { // 解决阻塞 UI 问题 - if (!$input.files || $input.files.length === 0) return - const file = $input.files[0] - this.uploadImg(file) - })() - } - $input.click() // 显示选择图片对话框 - } - - // 统一从 FileList 获取文件并上传图片方法 - const uploadFromFileList = (files?: FileList) => { - if (!files) return - - for (let i = 0; i < files.length; i++) { - const file = files[i] - this.uploadImg(file) - } - } - - // 拖拽图片 - // @link https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop - // 阻止浏览器的默认释放行为 - this.$textarea.addEventListener('dragover', (evt) => { - evt.stopPropagation() - evt.preventDefault() - }) - - this.$textarea.addEventListener('drop', (evt) => { - const files = evt.dataTransfer?.files - if (files?.length) { - evt.preventDefault() - uploadFromFileList(files) - } - }) - - // 粘贴图片 - this.$textarea.addEventListener('paste', (evt) => { - const files = evt.clipboardData?.files - if (files?.length) { - evt.preventDefault() - uploadFromFileList(files) - } - }) - } - - /** 刷新图片上传按钮 */ - refreshUploadBtn() { - if (!this.$imgUploadBtn) return - - if (!this.ctx.conf.imgUpload) { - this.$imgUploadBtn.setAttribute('atk-only-admin-show', '') - this.ctx.trigger('check-admin-show-el') - } - } - - async uploadImg(file: File) { - const fileExt = /[^.]+$/.exec(file.name) - if (!fileExt || !this.allowImgExts.includes(fileExt[0])) return - - // 未登录提示 - if (!this.ctx.user.checkHasBasicUserInfo()) { - this.showNotify('填入你的名字邮箱才能上传哦', 'w') - return - } - - // 插入图片前换一行 - let insertPrefix = '\n' - if (this.$textarea.value.trim() === '') insertPrefix = '' - - // 插入占位加载文字 - const uploadPlaceholderTxt = `${insertPrefix}![](Uploading ${file.name}...)` - this.insertContent(uploadPlaceholderTxt) - - // 上传图片 - let resp: any - try { - resp = await new Api(this.ctx).imgUpload(file) - } catch (err: any) { - console.error(err) - this.showNotify(`${this.$t('uploadFail')},${err.msg}`, 'e') - } - if (!!resp && resp.img_url) { - let imgURL = resp.img_url as string - - // 若为相对路径,加上 artalk server - if (!Utils.isValidURL(imgURL)) imgURL = Utils.getURLBasedOnApi(this.ctx, imgURL) - - // 上传成功插入图片 - this.setContent(this.$textarea.value.replace(uploadPlaceholderTxt, `${insertPrefix}![](${imgURL})`)) - } else { - // 上传失败删除加载文字 - this.setContent(this.$textarea.value.replace(uploadPlaceholderTxt, '')) - } - } - - insertContent (val: string) { - if ((document as any).selection) { - this.$textarea.focus(); - (document as any).selection.createRange().text = val - this.$textarea.focus() - } else if (this.$textarea.selectionStart || this.$textarea.selectionStart === 0) { - const sStart = this.$textarea.selectionStart - const sEnd = this.$textarea.selectionEnd - const sT = this.$textarea.scrollTop - this.setContent(this.$textarea.value.substring(0, sStart) + val + this.$textarea.value.substring(sEnd, this.$textarea.value.length)) - this.$textarea.focus() - this.$textarea.selectionStart = sStart + val.length - this.$textarea.selectionEnd = sStart + val.length - this.$textarea.scrollTop = sT - } else { - this.$textarea.focus() - this.$textarea.value += val - } - } - - setContent (val: string) { - this.$textarea.value = val - this.saveContent() - if (!!this.plugList && !!this.plugList.preview) { - this.plugList.preview.updateContent() - } - - // 延迟执行防止无效 - window.setTimeout(() => { - this.adjustTextareaHeight() - }, 80) - } - - clearEditor () { - this.setContent('') - this.cancelReply() - } - - /** 获取最终用于 submit 的数据 */ - getFinalContent () { - let content = this.getContentOriginal() - - // 表情包处理 - if (this.plugList && this.plugList.emoticons) { - const emoticonsPlug = this.plugList.emoticons as EmoticonsPlug - content = emoticonsPlug.transEmoticonImageText(content) - } - - return content - } - - getContentOriginal () { - return this.$textarea.value || '' // Tip: !!"0" === true - } - - getContentMarked () { - return Utils.marked(this.ctx, this.getFinalContent()) - } - - initBottomPart () { - this.initReply() - this.initSubmit() - } - - initReply () { - this.replyComment = null - this.$sendReply = null - } - - setReply (commentData: CommentData, $comment: HTMLElement, scroll = true) { - if (this.replyComment !== null) { - this.cancelReply() - } - - if (this.$sendReply === null) { - this.$sendReply = Utils.createElement(`
${this.$t('reply')} ×
`); - this.$sendReply.querySelector('.atk-text')!.innerText = `@${commentData.nick}` - this.$sendReply.addEventListener('click', () => { - this.cancelReply() - }) - this.$textareaWrap.append(this.$sendReply) - } - this.replyComment = commentData - - if (this.ctx.conf.editorTravel === true) { - this.travel($comment) - } - - if (scroll) Ui.scrollIntoView(this.$el) - - this.$textarea.focus() - } - - cancelReply () { - if (this.$sendReply !== null) { - this.$sendReply.remove() - this.$sendReply = null - } - this.replyComment = null - - if (this.ctx.conf.editorTravel === true) { - this.travelBack() - } - } - - initSubmit () { - this.$submitBtn.innerText = this.ctx.conf.sendBtn || this.$t('send') - - this.$submitBtn.addEventListener('click', (evt) => { - const btnEl = evt.currentTarget - this.submit() - }) - } - - async submit () { - if (this.getFinalContent().trim() === '') { - this.$textarea.focus() - return - } - - this.ctx.trigger('editor-submit') - - Ui.showLoading(this.$el) - - try { - const nComment = await new Api(this.ctx).add({ - content: this.getFinalContent(), - nick: this.user.data.nick, - email: this.user.data.email, - link: this.user.data.link, - rid: (this.replyComment === null) ? 0 : this.replyComment.id, - page_key: (this.replyComment === null) ? this.ctx.conf.pageKey : this.replyComment.page_key, - page_title: (this.replyComment === null) ? this.ctx.conf.pageTitle : undefined, - site_name: (this.replyComment === null) ? this.ctx.conf.site : this.replyComment.site_name - }) - - // 回复不同页面的评论 - if (this.replyComment !== null && this.replyComment.page_key !== this.ctx.conf.pageKey) { - window.open(`${this.replyComment.page_url}#atk-comment-${nComment.id}`) - } - - this.ctx.trigger('list-insert', nComment) - this.clearEditor() // 清空编辑器 - this.ctx.trigger('editor-submitted') - } catch (err: any) { - console.error(err) - this.showNotify(`${this.$t('commentFail')},${err.msg || String(err)}`, 'e') - return - } finally { - Ui.hideLoading(this.$el) - } - } - - showNotify (msg: string, type) { - Ui.showNotify(this.$notifyWrap, msg, type) - } - - /** 关闭评论 */ - close () { - if (!this.$textareaWrap.querySelector('.atk-comment-closed')) - this.$textareaWrap.prepend(Utils.createElement('
仅管理员可评论
')) - - if (!this.user.data.isAdmin) { - this.$textarea.style.display = 'none' - this.closePlug() - this.$bottom.style.display = 'none' - } else { - // 管理员一直打开评论 - this.$textarea.style.display = '' - this.$bottom.style.display = '' - } - } - - /** 打开评论 */ - open () { - this.$textareaWrap.querySelector('.atk-comment-closed')?.remove() - this.$textarea.style.display = '' - this.$bottom.style.display = '' - } - - travel ($afterEl: HTMLElement) { - if (this.isTraveling) return - this.isTraveling = true - this.$el.after(Utils.createElement('
')) - - const $travelPlace = Utils.createElement('
') - $afterEl.after($travelPlace) - $travelPlace.replaceWith(this.$el) - - this.$el.classList.add('atk-fade-in') // 添加渐入动画 - } - - travelBack () { - if (!this.isTraveling) return - this.isTraveling = false - this.ctx.$root.querySelector('.atk-editor-travel-placeholder')?.replaceWith(this.$el) - - // 取消回复 - if (this.replyComment !== null) this.cancelReply() - } - - initRemoteEmoticons () { - - } -} - diff --git a/packages/artalk/src/components/html/editor.html b/packages/artalk/src/components/html/editor.html deleted file mode 100644 index 2120103e2..000000000 --- a/packages/artalk/src/components/html/editor.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
- - - -
-
- -
- -
-
-
- -
-
-
-
diff --git a/packages/artalk/src/context.ts b/packages/artalk/src/context.ts index 96aa4aa39..79da0fcfd 100644 --- a/packages/artalk/src/context.ts +++ b/packages/artalk/src/context.ts @@ -1,45 +1,46 @@ import { marked as libMarked } from 'marked' import ArtalkConfig from '~/types/artalk-config' -import { EventPayloadMap, Event, EventScopeType, Handler } from '~/types/event' +import { Event } from '~/types/event' import { internal as internalLocales, I18n } from './i18n' import User from './lib/user' +import ContextApi from '../types/context' /** * Artalk Context */ -export default class Context { +export default class Context implements ContextApi { public cid: number // Context 唯一标识 - public $root: HTMLElement public conf: ArtalkConfig public user: User + public $root: HTMLElement private eventList: Event[] = [] - public constructor (rootEl: HTMLElement, conf: ArtalkConfig) { + public constructor($root: HTMLElement, conf: ArtalkConfig) { this.cid = +new Date() - this.$root = rootEl this.conf = conf - this.user = new User(this.conf) + this.user = new User(this) + this.$root = $root this.$root.setAttribute('atk-run-id', this.cid.toString()) } - public on(name: K, handler: Handler, scope: EventScopeType = 'internal') { - this.eventList.push({ name, handler: handler as any, scope }) + public on(name: any, handler: any, scope: any = 'internal') { + this.eventList.push({ name, handler, scope }) } - public off(name: K, handler?: Handler, scope: EventScopeType = 'internal') { + public off(name: any, handler: any, scope: any = 'internal') { this.eventList = this.eventList.filter((evt) => { if (handler) return !(evt.name === name && evt.handler === handler && evt.scope === scope) return !(evt.name === name && evt.scope === scope) // 删除全部相同 name event }) } - public trigger(name: K, payload?: EventPayloadMap[K], scope?: EventScopeType) { + public trigger(name: any, payload?: any, scope?: any) { this.eventList .filter((evt) => evt.name === name && (scope ? (evt.scope === scope) : true)) .map((evt) => evt.handler) - .forEach((handler) => handler(payload as any)) + .forEach((handler) => handler(payload)) } public markedInstance!: typeof libMarked diff --git a/packages/artalk/src/defaults.ts b/packages/artalk/src/defaults.ts index cc2b2fd95..c2402da97 100644 --- a/packages/artalk/src/defaults.ts +++ b/packages/artalk/src/defaults.ts @@ -3,6 +3,7 @@ import ArtalkConfig from "~/types/artalk-config" const defaults: ArtalkConfig = { el: '', pageKey: '', + pageTitle: '', server: '', site: '', diff --git a/packages/artalk/src/editor/editor.html b/packages/artalk/src/editor/editor.html new file mode 100644 index 000000000..0a64ef309 --- /dev/null +++ b/packages/artalk/src/editor/editor.html @@ -0,0 +1,18 @@ +
+
+ + + +
+
+ +
+ +
+
+
+ +
+
+
+
diff --git a/packages/artalk/src/editor/editor.ts b/packages/artalk/src/editor/editor.ts new file mode 100644 index 000000000..092387db1 --- /dev/null +++ b/packages/artalk/src/editor/editor.ts @@ -0,0 +1,420 @@ +import '@/style/editor.less' + +import { CommentData } from '~/types/artalk-data' +import Context from '~/types/context' +import Component from '../lib/component' +import * as Utils from '../lib/utils' +import * as Ui from '../lib/ui' +import Api from '../api' + +import EditorHTML from './editor.html?raw' + +import EmoticonsPlug from './plugs/emoticons-plug' +import UploadPlug from './plugs/upload-plug' +import PreviewPlug from './plugs/preview-plug' +import EditorPlug from './plugs/editor-plug' +import HeaderInputPlug from './plugs/header-input-plug' + +export default class Editor extends Component { + private get user() { return this.ctx.user } + + public $header: HTMLElement + public $nick: HTMLInputElement + public $email: HTMLInputElement + public $link: HTMLInputElement + public get $inputs() { + return { nick: this.$nick, email: this.$email, link: this.$link } + } + + public $textareaWrap: HTMLElement + public $textarea: HTMLTextAreaElement + public $bottom: HTMLElement + public $submitBtn: HTMLButtonElement + public $notifyWrap: HTMLElement + + private replyComment: CommentData|null = null + private $sendReply: HTMLElement|null = null + + private isTraveling = false + + /** 启用的插件 */ + private readonly ENABLED_PLUGS = [ EmoticonsPlug, UploadPlug, PreviewPlug, HeaderInputPlug ] + public plugList: { [name: string]: EditorPlug } = {} + private openedPlugName: string|null = null + public $plugPanelWrap: HTMLElement + public $plugBtnWrap: HTMLElement + + public constructor(ctx: Context) { + super(ctx) + + this.$el = Utils.createElement(EditorHTML) + + this.$header = this.$el.querySelector('.atk-header')! + this.$nick = this.$header.querySelector('[name="nick"]')! + this.$email = this.$header.querySelector('[name="email"]')! + this.$link = this.$header.querySelector('[name="link"]')! + + this.$textareaWrap = this.$el.querySelector('.atk-textarea-wrap')! + this.$textarea = this.$el.querySelector('.atk-textarea')! + this.$bottom = this.$el.querySelector('.atk-bottom')! + this.$submitBtn = this.$el.querySelector('.atk-send-btn')! + this.$notifyWrap = this.$el.querySelector('.atk-notify-wrap')! + + this.$plugBtnWrap = this.$el.querySelector('.atk-plug-btn-wrap')! + this.$plugPanelWrap = this.$el.querySelector('.atk-plug-panel-wrap')! + + // 执行初始化 + this.initLocalStorage() + this.initHeader() + this.initTextarea() + this.initPlugs() + this.initSubmitBtn() + + // 监听事件 + this.ctx.on('editor-open', () => (this.open())) + this.ctx.on('editor-close', () => (this.close())) + this.ctx.on('editor-reply', (p) => (this.setReply(p.data, p.$el, p.scroll))) + this.ctx.on('editor-reply-cancel', () => (this.cancelReply())) + this.ctx.on('editor-show-loading', () => (Ui.showLoading(this.$el))) + this.ctx.on('editor-hide-loading', () => (Ui.hideLoading(this.$el))) + this.ctx.on('editor-notify', (f) => (this.showNotify(f.msg, f.type))) + this.ctx.on('editor-travel', ($el) => (this.travel($el))) + this.ctx.on('editor-travel-back', () => (this.travelBack())) + this.ctx.on('conf-updated', () => {}) + } + + private initLocalStorage() { + const localContent = window.localStorage.getItem('ArtalkContent') || '' + if (localContent.trim() !== '') { + this.showNotify(this.$t('restoredMsg'), 'i') + this.setContent(localContent) + } + + this.$textarea.addEventListener('input', () => (this.saveToLocalStorage())) + } + + private saveToLocalStorage() { + window.localStorage.setItem('ArtalkContent', this.getContentOriginal().trim()) + } + + private initHeader() { + Object.entries(this.$inputs).forEach(([key, $input]) => { + $input.value = this.user.data[key] || '' + $input.addEventListener('input', () => this.onHeaderInput(key, $input)) + + // 设置 Placeholder + $input.placeholder = `${this.$t(key as any)}` + }) + } + + private onHeaderInput(key: string, $input: HTMLInputElement) { + this.user.update({ + [key]: $input.value.trim() + }) + + // 插件监听事件响应 + Object.entries(this.plugList).forEach(([plugName, plug]) => { + if (plug.onHeaderInput) plug.onHeaderInput(key, $input) + }) + } + + private initTextarea() { + // 占位符 + this.$textarea.placeholder = this.ctx.conf.placeholder || this.$t('placeholder') + + // 修复按下 Tab 输入的内容 + this.$textarea.addEventListener('keydown', (e) => { + const keyCode = e.keyCode || e.which + + if (keyCode === 9) { + e.preventDefault() + this.insertContent('\t') + } + }) + + // 输入框高度随内容而变化 + this.$textarea.addEventListener('input', () => { + this.adjustTextareaHeight() + }) + } + + private initSubmitBtn() { + this.$submitBtn.innerText = this.ctx.conf.sendBtn || this.$t('send') + this.$submitBtn.addEventListener('click', () => (this.submit())) + } + + /** 最终用于 submit 的数据 */ + public getFinalContent() { + let content = this.getContentOriginal() + + // 表情包处理 + if (this.plugList.emoticons) { + content = (this.plugList.emoticons as EmoticonsPlug).transEmoticonImageText(content) + } + + return content + } + + public getContentOriginal() { + return this.$textarea.value || '' // Tip: !!"0" === true + } + + public getContentMarked() { + return Utils.marked(this.ctx, this.getFinalContent()) + } + + public setContent(val: string) { + this.$textarea.value = val + this.saveToLocalStorage() + if (this.plugList.preview) { + ;(this.plugList.preview as PreviewPlug).updateContent() + } + + // 延迟执行防止无效 + window.setTimeout(() => { + this.adjustTextareaHeight() + }, 80) + } + + public insertContent(val: string) { + if ((document as any).selection) { + this.$textarea.focus(); + (document as any).selection.createRange().text = val + this.$textarea.focus() + } else if (this.$textarea.selectionStart || this.$textarea.selectionStart === 0) { + const sStart = this.$textarea.selectionStart + const sEnd = this.$textarea.selectionEnd + const sT = this.$textarea.scrollTop + this.setContent(this.$textarea.value.substring(0, sStart) + val + this.$textarea.value.substring(sEnd, this.$textarea.value.length)) + this.$textarea.focus() + this.$textarea.selectionStart = sStart + val.length + this.$textarea.selectionEnd = sStart + val.length + this.$textarea.scrollTop = sT + } else { + this.$textarea.focus() + this.$textarea.value += val + } + } + + public clearEditor() { + this.setContent('') + this.cancelReply() + } + + private adjustTextareaHeight() { + const diff = this.$textarea.offsetHeight - this.$textarea.clientHeight + this.$textarea.style.height = '0px' // it's a magic. 若不加此行,内容减少,高度回不去 + this.$textarea.style.height = `${this.$textarea.scrollHeight + diff}px` + } + + public setReply(commentData: CommentData, $comment: HTMLElement, scroll = true) { + if (this.replyComment !== null) { + this.cancelReply() + } + + if (this.$sendReply === null) { + this.$sendReply = Utils.createElement(`
${this.$t('reply')} ×
`); + this.$sendReply.querySelector('.atk-text')!.innerText = `@${commentData.nick}` + this.$sendReply.addEventListener('click', () => { + this.cancelReply() + }) + this.$textareaWrap.append(this.$sendReply) + } + this.replyComment = commentData + + if (this.ctx.conf.editorTravel === true) { + this.travel($comment) + } + + if (scroll) Ui.scrollIntoView(this.$el) + + this.$textarea.focus() + } + + public cancelReply() { + if (this.$sendReply !== null) { + this.$sendReply.remove() + this.$sendReply = null + } + this.replyComment = null + + if (this.ctx.conf.editorTravel === true) { + this.travelBack() + } + } + + public showNotify(msg: string, type: "i"|"s"|"w"|"e") { + Ui.showNotify(this.$notifyWrap, msg, type) + } + + /** 提交评论 */ + public async submit() { + if (this.getFinalContent().trim() === '') { + this.$textarea.focus() + return + } + + this.ctx.trigger('editor-submit') + + Ui.showLoading(this.$el) + + try { + const nComment = await new Api(this.ctx).add({ + content: this.getFinalContent(), + nick: this.user.data.nick, + email: this.user.data.email, + link: this.user.data.link, + rid: (this.replyComment === null) ? 0 : this.replyComment.id, + page_key: (this.replyComment === null) ? this.ctx.conf.pageKey : this.replyComment.page_key, + page_title: (this.replyComment === null) ? this.ctx.conf.pageTitle : undefined, + site_name: (this.replyComment === null) ? this.ctx.conf.site : this.replyComment.site_name + }) + + // 回复不同页面的评论 + if (this.replyComment !== null && this.replyComment.page_key !== this.ctx.conf.pageKey) { + window.open(`${this.replyComment.page_url}#atk-comment-${nComment.id}`) + } + + this.ctx.trigger('list-insert', nComment) + this.clearEditor() // 清空编辑器 + this.ctx.trigger('editor-submitted') + } catch (err: any) { + console.error(err) + this.showNotify(`${this.$t('commentFail')},${err.msg || String(err)}`, 'e') + return + } finally { + Ui.hideLoading(this.$el) + } + } + + /** 关闭评论 */ + public close() { + if (!this.$textareaWrap.querySelector('.atk-comment-closed')) + this.$textareaWrap.prepend(Utils.createElement('
仅管理员可评论
')) + + if (!this.user.data.isAdmin) { + this.$textarea.style.display = 'none' + this.closePlugPanel() + this.$bottom.style.display = 'none' + } else { + // 管理员一直打开评论 + this.$textarea.style.display = '' + this.$bottom.style.display = '' + } + } + + /** 打开评论 */ + public open() { + this.$textareaWrap.querySelector('.atk-comment-closed')?.remove() + this.$textarea.style.display = '' + this.$bottom.style.display = '' + } + + /** 移动评论框到置顶元素之后 */ + public travel($afterEl: HTMLElement) { + if (this.isTraveling) return + this.isTraveling = true + this.$el.after(Utils.createElement('
')) + + const $travelPlace = Utils.createElement('
') + $afterEl.after($travelPlace) + $travelPlace.replaceWith(this.$el) + + this.$el.classList.add('atk-fade-in') // 添加渐入动画 + } + + /** 评论框归位 */ + public travelBack() { + if (!this.isTraveling) return + this.isTraveling = false + this.ctx.$root.querySelector('.atk-editor-travel-placeholder')?.replaceWith(this.$el) + + // 取消回复 + if (this.replyComment !== null) this.cancelReply() + } + + /** 插件初始化 */ + private initPlugs() { + this.plugList = {} + this.$plugPanelWrap.innerHTML = '' + this.$plugPanelWrap.style.display = 'none' + this.openedPlugName = null + this.$plugBtnWrap.innerHTML = '' + + const disabledPlugs: string[] = [] + if (!this.conf.emoticons) disabledPlugs.push('emoticons') + + // 初始化 Editor 插件 + this.ENABLED_PLUGS.forEach((Plug) => { + const plugName = Plug.Name + + // 禁用的插件 + if (disabledPlugs.includes(plugName)) return + + // 插件对象实例化 + const plug = new Plug(this) + this.plugList[plugName] = plug + + // 插件按钮 + const $btn = plug.getBtn() + if ($btn) { + this.$plugBtnWrap.appendChild($btn) + $btn.onclick = $btn.onclick || (() => { + // 其他按钮去除 Active + this.$plugBtnWrap.querySelectorAll('.active').forEach(item => item.classList.remove('active')) + + // 若点击已打开的,则关闭打开的面板 + if (plugName === this.openedPlugName) { + this.closePlugPanel() + return + } + + this.openPlugPanel(plugName) + + // 当前按钮添加 Active + $btn.classList.add('active') + }) + + // 插件面板初始化 + const $panel = plug.getPanel() + if ($panel) { + $panel.setAttribute('data-plug-name', plugName) + $panel.style.display = 'none' + this.$plugPanelWrap.appendChild($panel) + } + } + }) + } + + /** 展开插件面板 */ + public openPlugPanel(plugName: string) { + Object.entries(this.plugList).forEach(([aPlugName, plug]) => { + const plugPanel = plug.getPanel() + if (!plugPanel) return + + if (aPlugName === plugName) { + plugPanel.style.display = '' + if (plug.onPanelShow) plug.onPanelShow() + } else { + plugPanel.style.display = 'none' + if (plug.onPanelHide) plug.onPanelHide() + } + }) + + this.$plugPanelWrap.style.display = '' + this.openedPlugName = plugName + } + + /** 收起插件面板 */ + public closePlugPanel() { + if (!this.openedPlugName) return + + const plug = this.plugList[this.openedPlugName] + if (!plug) return + + if (plug.onPanelHide) plug.onPanelHide() + + this.$plugPanelWrap.style.display = 'none' + this.openedPlugName = null + } +} diff --git a/packages/artalk/src/editor/index.ts b/packages/artalk/src/editor/index.ts new file mode 100644 index 000000000..2916278ad --- /dev/null +++ b/packages/artalk/src/editor/index.ts @@ -0,0 +1,3 @@ +import Editor from './editor' + +export default Editor diff --git a/packages/artalk/src/editor/plugs/editor-plug.ts b/packages/artalk/src/editor/plugs/editor-plug.ts new file mode 100644 index 000000000..4b1823fa8 --- /dev/null +++ b/packages/artalk/src/editor/plugs/editor-plug.ts @@ -0,0 +1,51 @@ +import * as Utils from '@/lib/utils' +import Editor from '../editor' + +/** + * Editor 插件 + * + * @desc 使用 Interface x Abstract 合并声明 + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces + */ +interface EditorPlug { + onPanelShow?(): void + onPanelHide?(): void +} + +abstract class EditorPlug { + protected editor: Editor + protected get ctx() { return this.editor.ctx } + protected $panel?: HTMLElement + protected $btn?: HTMLElement + public onHeaderInput?: (key: string, $input: HTMLElement) => void + + public constructor(editor: Editor) { + this.editor = editor + } + + public static Name: string + + protected registerPanel(html: string = '
') { + this.$panel = Utils.createElement(html) + return this.$panel + } + + protected registerBtn(html: string) { + this.$btn = Utils.createElement(`${html}`) + return this.$btn + } + + protected registerHeaderInputEvt(action: (key: string, $input: HTMLElement) => void) { + this.onHeaderInput = action + } + + public getPanel() { + return this.$panel + } + + public getBtn() { + return this.$btn + } +} + +export default EditorPlug diff --git a/packages/artalk/src/components/editor-plugs/emoticons-plug.less b/packages/artalk/src/editor/plugs/emoticons-plug.less similarity index 100% rename from packages/artalk/src/components/editor-plugs/emoticons-plug.less rename to packages/artalk/src/editor/plugs/emoticons-plug.less diff --git a/packages/artalk/src/components/editor-plugs/emoticons-plug.ts b/packages/artalk/src/editor/plugs/emoticons-plug.ts similarity index 79% rename from packages/artalk/src/components/editor-plugs/emoticons-plug.ts rename to packages/artalk/src/editor/plugs/emoticons-plug.ts index a132b815f..2f92933c6 100644 --- a/packages/artalk/src/components/editor-plugs/emoticons-plug.ts +++ b/packages/artalk/src/editor/plugs/emoticons-plug.ts @@ -14,24 +14,53 @@ type OwOFormatType = { } export default class EmoticonsPlug extends EditorPlug { - public $el!: HTMLElement + public static Name = 'emoticons' + + declare protected $panel: HTMLElement + public emoticons: EmoticonListData = [] + public loadingTask: Promise|null = null public $grpWrap!: HTMLElement public $grpSwitcher!: HTMLElement - public loadingTask: Promise|null = null - - constructor (public editor: Editor) { + public constructor(public editor: Editor) { super(editor) - this.$el = Utils.createElement(`
`) + this.registerPanel(`
`) + this.registerBtn(this.ctx.$t('emoticon')) + + // 表情包预加载 + window.setTimeout(() => { + this.loadEmoticonsData() + }, 1000) // 延迟 1s 加载 + } + + public onPanelShow() { + ;(async () => { + await this.loadEmoticonsData() + + // 初始化元素 + if (!this.isImgLoaded) { + this.initEmoticonsList() + this.isImgLoaded = true + } + + // 延迟执行,防止无法读取高度 + setTimeout(() => { + this.changeListHeight() + }, 30) + })() + } + + public onPanelHide() { + this.$panel.parentElement!.style.height = '' } isListLoaded = false isImgLoaded = false - async loadEmoticonsData() { + public async loadEmoticonsData() { if (this.isListLoaded) return if (this.loadingTask !== null) { await this.loadingTask @@ -40,23 +69,23 @@ export default class EmoticonsPlug extends EditorPlug { // 数据处理 this.loadingTask = (async () => { - Ui.showLoading(this.$el) + Ui.showLoading(this.$panel) this.emoticons = await this.handleData(this.ctx.conf.emoticons) - Ui.hideLoading(this.$el) + Ui.hideLoading(this.$panel) this.loadingTask = null this.isListLoaded = true })() await this.loadingTask } - async handleData(data: any): Promise { + private async handleData(data: any): Promise { if (!Array.isArray(data) && ['object', 'string'].includes(typeof data)) { data = [data] } if (!Array.isArray(data)) { - Ui.setError(this.$el, "表情包数据必须为 Array/Object/String 类型") - Ui.hideLoading(this.$el) + Ui.setError(this.$panel, "表情包数据必须为 Array/Object/String 类型") + Ui.hideLoading(this.$panel) return [] } @@ -105,7 +134,7 @@ export default class EmoticonsPlug extends EditorPlug { } /** 远程加载 */ - async remoteLoad(url: string): Promise { + private async remoteLoad(url: string): Promise { if (!url) return [] try { @@ -113,14 +142,14 @@ export default class EmoticonsPlug extends EditorPlug { const json = await resp.json() return json } catch (err) { - Ui.hideLoading(this.$el) - Ui.setError(this.$el, `表情加载失败 ${String(err)}`) + Ui.hideLoading(this.$panel) + Ui.setError(this.$panel, `表情加载失败 ${String(err)}`) return [] } } /** 避免表情 item.key 为 null 的情况 */ - solveNullKey(data: EmoticonGrpData[]) { + private solveNullKey(data: EmoticonGrpData[]) { data.forEach((grp) => { grp.items.forEach((item, index) => { if (!item.key) item.key = `${grp.name} ${index+1}` @@ -129,7 +158,7 @@ export default class EmoticonsPlug extends EditorPlug { } /** 避免相同 item.key */ - solveSameKey(data: EmoticonGrpData[]) { + private solveSameKey(data: EmoticonGrpData[]) { const tmp: {[key: string]: number} = {} data.forEach((grp) => { grp.items.forEach(item => { @@ -143,7 +172,7 @@ export default class EmoticonsPlug extends EditorPlug { } /** 判断是否为 OwO 格式 */ - isOwOFormat(data: any) { + private isOwOFormat(data: any) { try { return (typeof data === 'object') && !!Object.values(data).length && Array.isArray(Object.keys(Object.values(data)[0].container)) @@ -152,7 +181,7 @@ export default class EmoticonsPlug extends EditorPlug { } /** 转换 OwO 格式数据 */ - convertOwO(owoData: OwOFormatType): EmoticonListData { + private convertOwO(owoData: OwOFormatType): EmoticonListData { const dest: EmoticonListData = [] Object.entries(owoData).forEach(([grpName, grp]) => { const nGrp: EmoticonGrpData = { name: grpName, type: grp.type, items: [] } @@ -171,10 +200,10 @@ export default class EmoticonsPlug extends EditorPlug { } /** 初始化表情列表界面 */ - initEmoticonsList () { + private initEmoticonsList() { // 表情列表 this.$grpWrap = Utils.createElement(`
`) - this.$el.append(this.$grpWrap) + this.$panel.append(this.$grpWrap) this.emoticons.forEach((grp, index) => { const $grp = Utils.createElement(``) @@ -208,24 +237,25 @@ export default class EmoticonsPlug extends EditorPlug { }) }) - // 表情分类 - this.$grpSwitcher = Utils.createElement(`
`) - this.$el.append(this.$grpSwitcher) - this.emoticons.forEach((grp, index) => { - const $item = Utils.createElement('') - $item.innerText = grp.name - $item.setAttribute('data-index', String(index)) - $item.onclick = () => (this.openGrp(index)) - this.$grpSwitcher.append($item) - }) + // 表情分类切换 bar + if (this.emoticons.length > 1) { + this.$grpSwitcher = Utils.createElement(`
`) + this.$panel.append(this.$grpSwitcher) + this.emoticons.forEach((grp, index) => { + const $item = Utils.createElement('') + $item.innerText = grp.name + $item.setAttribute('data-index', String(index)) + $item.onclick = () => (this.openGrp(index)) + this.$grpSwitcher.append($item) + }) + } // 默认打开第一个分类 - if (this.emoticons.length > 0) - this.openGrp(0) + if (this.emoticons.length > 0) this.openGrp(0) } /** 打开一个表情组 */ - openGrp (index: number) { + public openGrp(index: number) { Array.from(this.$grpWrap.children).forEach((item) => { const el = item as HTMLElement if (el.getAttribute('data-index') !== String(index)) { @@ -241,41 +271,13 @@ export default class EmoticonsPlug extends EditorPlug { this.changeListHeight() } - static Name = 'emoticons' - static BtnHTML = '表情' - - getEl () { - return this.$el - } - - changeListHeight () { + private changeListHeight() { /* const listWrapHeight = Utils.getHeight(this.$grpWrapem) this.editor.plugWrapEl.style.height = `${listWrapHeight > 150 ? listWrapHeight : 150}px` */ } - onShow () { - ;(async () => { - await this.loadEmoticonsData() - - // 初始化元素 - if (!this.isImgLoaded) { - this.initEmoticonsList() - this.isImgLoaded = true - } - - // 延迟执行,防止无法读取高度 - setTimeout(() => { - this.changeListHeight() - }, 30) - })() - } - - onHide () { - this.$el.parentElement!.style.height = '' - } - /** 处理评论 content 中的表情内容 */ - public transEmoticonImageText (text: string) { + public transEmoticonImageText(text: string) { if (!this.emoticons || !Array.isArray(this.emoticons)) return text diff --git a/packages/artalk/src/editor/plugs/header-input-plug.ts b/packages/artalk/src/editor/plugs/header-input-plug.ts new file mode 100644 index 000000000..9173ebc89 --- /dev/null +++ b/packages/artalk/src/editor/plugs/header-input-plug.ts @@ -0,0 +1,79 @@ +import Api from '@/api' +import Editor from '../editor' +import EditorPlug from './editor-plug' + +export default class HeaderInputPlug extends EditorPlug { + public static Name = 'headerInput' + + public constructor(editor: Editor) { + super(editor) + + this.registerHeaderInputEvt((key, $input) => { + if (key === 'nick' || key === 'email') { + this.fetchUserInfo() + } + }) + + // Link URL 自动补全协议 + this.editor.$link.addEventListener('change', () => { + const link = this.editor.$link.value.trim() + if (!!link && !/^(http|https):\/\//.test(link)) { + this.editor.$link.value = `https://${link}` + this.ctx.user.update({ link: this.editor.$link.value }) + } + }) + } + + queryUserInfo = { + timeout: null, + abortFunc: <(() => void)|null>null + } + + /** 远程获取用户数据 */ + fetchUserInfo() { + this.ctx.user.logout() + + // 获取用户信息 + if (this.queryUserInfo.timeout) window.clearTimeout(this.queryUserInfo.timeout) // 清除待发出的请求 + if (this.queryUserInfo.abortFunc) this.queryUserInfo.abortFunc() // 之前发出未完成的请求立刻中止 + + this.queryUserInfo.timeout = window.setTimeout(() => { + this.queryUserInfo.timeout = null // 清理 + + const {req, abort} = new Api(this.ctx).userGet( + this.ctx.user.data.nick, this.ctx.user.data.email + ) + this.queryUserInfo.abortFunc = abort + req.then(data => { + if (!data.is_login) { + this.ctx.user.logout() + } + + // 未读消息更新 + this.ctx.trigger('unread-update', { notifies: data.unread, }) + + // 若用户为管理员,执行登陆操作 + if (this.ctx.user.checkHasBasicUserInfo() && !data.is_login && data.user?.is_admin) { + this.showLoginDialog() + } + + // 自动填入 link + if (data.user && data.user.link) { + this.editor.$link.value = data.user.link + this.ctx.user.update({ link: data.user.link }) + } + }) + .catch(() => {}) + .finally(() => { + this.queryUserInfo.abortFunc = null // 清理 + }) + }, 400) // 延迟执行,减少请求次数 + } + + showLoginDialog() { + this.ctx.trigger('checker-admin', { + onSuccess: () => { + } + }) + } +} diff --git a/packages/artalk/src/components/editor-plugs/preview-plug.less b/packages/artalk/src/editor/plugs/preview-plug.less similarity index 100% rename from packages/artalk/src/components/editor-plugs/preview-plug.less rename to packages/artalk/src/editor/plugs/preview-plug.less diff --git a/packages/artalk/src/editor/plugs/preview-plug.ts b/packages/artalk/src/editor/plugs/preview-plug.ts new file mode 100644 index 000000000..108d6926a --- /dev/null +++ b/packages/artalk/src/editor/plugs/preview-plug.ts @@ -0,0 +1,37 @@ +import './preview-plug.less' + +import Editor from '../editor' +import EditorPlug from './editor-plug' + +export default class PreviewPlug extends EditorPlug { + public static Name = 'preview' + declare protected $panel: HTMLElement + + private isBind = false + + public constructor(editor: Editor) { + super(editor) + + this.registerPanel(`
`) + this.registerBtn(`${this.editor.$t('preview')} `) + } + + public onPanelShow() { + this.updateContent() + + if (!this.isBind) { + const event = () => { + this.updateContent() + } + this.editor.$textarea.addEventListener('input', event) + this.editor.$textarea.addEventListener('change', event) + this.isBind = true + } + } + + public updateContent() { + if (this.$panel.style.display !== 'none') { + this.$panel.innerHTML = this.editor.getContentMarked() + } + } +} diff --git a/packages/artalk/src/editor/plugs/upload-plug.ts b/packages/artalk/src/editor/plugs/upload-plug.ts new file mode 100644 index 000000000..8f0e648ab --- /dev/null +++ b/packages/artalk/src/editor/plugs/upload-plug.ts @@ -0,0 +1,119 @@ +import * as Utils from '@/lib/utils' +import Api from '@/api' +import Editor from '../editor' +import EditorPlug from './editor-plug' + +export default class UploadPlug extends EditorPlug { + public static Name = 'upload' + + public $imgUploadInput?: HTMLInputElement + + /** 允许的图片格式 */ + allowImgExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'] + + public constructor(editor: Editor) { + super(editor) + + this.$imgUploadInput = document.createElement('input') + this.$imgUploadInput.type = 'file' + this.$imgUploadInput.style.display = 'none' + this.$imgUploadInput.accept = this.allowImgExts.map(o => `.${o}`).join(',') + + const $btn = this.registerBtn(`${this.ctx.$t('image')}`) + $btn.after(this.$imgUploadInput) + $btn.onclick = () => { + // 选择图片 + const $input = this.$imgUploadInput! + $input.onchange = () => { + (async () => { // 解决阻塞 UI 问题 + if (!$input.files || $input.files.length === 0) return + const file = $input.files[0] + this.uploadImg(file) + })() + } + $input.click() // 显示选择图片对话框 + } + + this.ctx.on('conf-updated', () => { + if (!this.ctx.conf.imgUpload) { + this.getBtn()!.setAttribute('atk-only-admin-show', '') + this.ctx.trigger('check-admin-show-el') + } + }) + + // 统一从 FileList 获取文件并上传图片方法 + const uploadFromFileList = (files?: FileList) => { + if (!files) return + + for (let i = 0; i < files.length; i++) { + const file = files[i] + this.uploadImg(file) + } + } + + // 拖拽图片 + // @link https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop + // 阻止浏览器的默认释放行为 + this.editor.$textarea.addEventListener('dragover', (evt) => { + evt.stopPropagation() + evt.preventDefault() + }) + + this.editor.$textarea.addEventListener('drop', (evt) => { + const files = evt.dataTransfer?.files + if (files?.length) { + evt.preventDefault() + uploadFromFileList(files) + } + }) + + // 粘贴图片 + this.editor.$textarea.addEventListener('paste', (evt) => { + const files = evt.clipboardData?.files + if (files?.length) { + evt.preventDefault() + uploadFromFileList(files) + } + }) + } + + public async uploadImg(file: File) { + const fileExt = /[^.]+$/.exec(file.name) + if (!fileExt || !this.allowImgExts.includes(fileExt[0])) return + + // 未登录提示 + if (!this.ctx.user.checkHasBasicUserInfo()) { + this.editor.showNotify('填入你的名字邮箱才能上传哦', 'w') + return + } + + // 插入图片前换一行 + let insertPrefix = '\n' + if (this.editor.$textarea.value.trim() === '') insertPrefix = '' + + // 插入占位加载文字 + const uploadPlaceholderTxt = `${insertPrefix}![](Uploading ${file.name}...)` + this.editor.insertContent(uploadPlaceholderTxt) + + // 上传图片 + let resp: any + try { + resp = await new Api(this.ctx).imgUpload(file) + } catch (err: any) { + console.error(err) + this.editor.showNotify(`${this.ctx.$t('uploadFail')},${err.msg}`, 'e') + } + if (!!resp && resp.img_url) { + let imgURL = resp.img_url as string + + // 若为相对路径,加上 artalk server + if (!Utils.isValidURL(imgURL)) imgURL = Utils.getURLBasedOnApi(this.ctx, imgURL) + + // 上传成功插入图片 + this.editor.setContent(this.editor.$textarea.value.replace(uploadPlaceholderTxt, `${insertPrefix}![](${imgURL})`)) + } else { + // 上传失败删除加载文字 + this.editor.setContent(this.editor.$textarea.value.replace(uploadPlaceholderTxt, '')) + } + } +} diff --git a/packages/artalk/src/layer/index.ts b/packages/artalk/src/layer/index.ts new file mode 100644 index 000000000..ce9f6e3ae --- /dev/null +++ b/packages/artalk/src/layer/index.ts @@ -0,0 +1,4 @@ +import Layer, { GetLayerWrap } from './layer' + +export default Layer +export { GetLayerWrap } diff --git a/packages/artalk/src/components/layer.ts b/packages/artalk/src/layer/layer.ts similarity index 94% rename from packages/artalk/src/components/layer.ts rename to packages/artalk/src/layer/layer.ts index 56288e7b0..334eac399 100644 --- a/packages/artalk/src/components/layer.ts +++ b/packages/artalk/src/layer/layer.ts @@ -1,4 +1,4 @@ -import Context from '../context' +import Context from '~/types/context' import Component from '../lib/component' import * as Utils from '../lib/utils' import * as Ui from '../lib/ui' @@ -40,19 +40,19 @@ export default class Layer extends Component { this.$wrap.append(this.$el) } - getName () { + getName() { return this.name } - getWrapEl () { + getWrapEl() { return this.$wrap } - getEl () { + getEl() { return this.$el } - show () { + show() { this.fireAllActionTimer() this.$wrap.style.display = 'block' @@ -126,7 +126,7 @@ export default class Layer extends Component { } /** 销毁 - 无动画 */ - disposeNow () { + disposeNow() { this.$el.remove() this.pageBodyScrollBarShow() // this.$el dispose @@ -134,21 +134,21 @@ export default class Layer extends Component { } /** 销毁 */ - dispose () { + dispose() { this.hide() this.$el.remove() // this.$el dispose this.checkCleanLayer() } - checkCleanLayer () { + checkCleanLayer() { if (this.getWrapEl().querySelectorAll('.atk-layer-item').length === 0) { this.$wrap.style.display = 'none' } } } -export function GetLayerWrap (ctx: Context): { $wrap: HTMLElement, $mask: HTMLElement } { +export function GetLayerWrap(ctx: Context): { $wrap: HTMLElement, $mask: HTMLElement } { let $wrap = document.querySelector(`.atk-layer-wrap#ctx-${ctx.cid}`) if (!$wrap) { $wrap = Utils.createElement( diff --git a/packages/artalk/src/components/html/sidebar-layer.html b/packages/artalk/src/layer/sidebar-layer.html similarity index 100% rename from packages/artalk/src/components/html/sidebar-layer.html rename to packages/artalk/src/layer/sidebar-layer.html diff --git a/packages/artalk/src/components/sidebar-layer.ts b/packages/artalk/src/layer/sidebar-layer.ts similarity index 73% rename from packages/artalk/src/components/sidebar-layer.ts rename to packages/artalk/src/layer/sidebar-layer.ts index f532b30da..39fe46e9b 100644 --- a/packages/artalk/src/components/sidebar-layer.ts +++ b/packages/artalk/src/layer/sidebar-layer.ts @@ -1,11 +1,11 @@ import '../style/sidebar-layer.less' -import Context from '@/context' +import Context from '~/types/context' import Component from '@/lib/component' import * as Utils from '@/lib/utils' import * as Ui from '@/lib/ui' import { SidebarShowPayload } from '~/types/event' -import SidebarHTML from './html/sidebar-layer.html?raw' +import SidebarHTML from './sidebar-layer.html?raw' import Layer from './layer' import Api from '../api' @@ -125,7 +125,7 @@ export default class SidebarLayer extends Component { this.layer?.hide() } - public iframeLoad(src: string) { + private iframeLoad(src: string) { if (!this.$iframe) return this.$iframe.src = src @@ -135,39 +135,5 @@ export default class SidebarLayer extends Component { this.$iframe.onload = () => { Ui.hideLoading(this.$iframeWrap) } - - // this.checkReqStatus(src) // 判不准,删了,没啥用 - } - - loadingTimer: number|null = null - - // 测试可访问性 (由于 iframe 测不准,需要额外请求) - public async checkReqStatus(url: string) { - if (this.loadingTimer !== null) window.clearTimeout(this.loadingTimer) - - this.loadingTimer = window.setTimeout(async () => { - try { - await fetch(url) - } catch (err) { - console.log(err) - // 请求失败,显示错误提示 - const $errAlert = Utils.createElement( - `
` + - `
侧边栏似乎打开失败
` + - `
重新加载 / 取消
` + - `
` - ) - const $reloadBtn = $errAlert.querySelector('#AtkReload')! - const $cancelBtn = $errAlert.querySelector('#AtkCancel')! - $reloadBtn.onclick = () => { - this.iframeLoad(url) - $errAlert.remove() - } - $cancelBtn.onclick = () => { // 提供取消按钮,防止误判 - $errAlert.remove() - } - this.$iframeWrap.append($errAlert) - } - }, 2000) // 2s 后开始检测 } } diff --git a/packages/artalk/src/lib/checker/admin-checker.ts b/packages/artalk/src/lib/checker/admin-checker.ts index 2011ffbcc..0077b9bab 100644 --- a/packages/artalk/src/lib/checker/admin-checker.ts +++ b/packages/artalk/src/lib/checker/admin-checker.ts @@ -20,9 +20,10 @@ const AdminChecker: Checker = { }, onSuccess(that, ctx, userToken, inputVal, formEl) { - that.ctx.user.data.isAdmin = true - that.ctx.user.data.token = userToken - that.ctx.user.save() + that.ctx.user.update({ + isAdmin: true, + token: userToken + }) that.ctx.trigger('user-changed', that.ctx.user.data) that.ctx.trigger('list-reload') }, diff --git a/packages/artalk/src/lib/checker/index.ts b/packages/artalk/src/lib/checker/index.ts index 31b063c5e..bd1eab021 100644 --- a/packages/artalk/src/lib/checker/index.ts +++ b/packages/artalk/src/lib/checker/index.ts @@ -1,7 +1,7 @@ -import Context from '@/context' -import Layer from '@/components/layer' -import Dialog from '@/components/dialog' +import Context from '~/types/context' import { CheckerPayload } from '~/types/event' +import Dialog from '@/components/dialog' +import Layer from '@/layer' import * as Utils from '../utils' import * as Ui from '../ui' import CaptchaChecker from './captcha-checker' diff --git a/packages/artalk/src/lib/component.ts b/packages/artalk/src/lib/component.ts index e30798820..9231979bf 100644 --- a/packages/artalk/src/lib/component.ts +++ b/packages/artalk/src/lib/component.ts @@ -1,8 +1,8 @@ import ArtalkConfig from '~/types/artalk-config' -import Context from '../context' +import Context from '~/types/context' import { I18n } from '../i18n' -export default class Component { +export default abstract class Component { public $el!: HTMLElement public ctx: Context diff --git a/packages/artalk/src/lib/ui.ts b/packages/artalk/src/lib/ui.ts index f8950e6bc..1e627177b 100644 --- a/packages/artalk/src/lib/ui.ts +++ b/packages/artalk/src/lib/ui.ts @@ -1,10 +1,7 @@ import * as Utils from './utils' -import Context from '../context' /** 显示加载 */ -export function showLoading(parentElem: HTMLElement|Context, conf?: { transparentBg?: boolean }) { - if (parentElem instanceof Context) parentElem = parentElem.$root - +export function showLoading(parentElem: HTMLElement, conf?: { transparentBg?: boolean }) { let $loading = parentElem.querySelector('.atk-loading') if (!$loading) { $loading = Utils.createElement( @@ -29,9 +26,7 @@ export function showLoading(parentElem: HTMLElement|Context, conf?: { transparen } /** 隐藏加载 */ -export function hideLoading(parentElem: HTMLElement|Context) { - if (parentElem instanceof Context) parentElem = parentElem.$root - +export function hideLoading(parentElem: HTMLElement) { const $loading = parentElem.querySelector('.atk-loading') if ($loading) $loading.style.display = 'none' } @@ -133,9 +128,7 @@ export function playFadeOutAnim(elem: HTMLElement, after?: () => void) { } /** 设置全局错误 */ -export function setError(parentElem: HTMLElement|Context, html: string | HTMLElement | null, title: string = 'Artalk Error') { - if (parentElem instanceof Context) parentElem = parentElem.$root - +export function setError(parentElem: HTMLElement, html: string | HTMLElement | null, title: string = 'Artalk Error') { let elem = parentElem.querySelector('.atk-error-layer') if (html === null) { if (elem !== null) elem.remove() diff --git a/packages/artalk/src/lib/user.ts b/packages/artalk/src/lib/user.ts index 12ca79b6b..1ed26c0f8 100644 --- a/packages/artalk/src/lib/user.ts +++ b/packages/artalk/src/lib/user.ts @@ -1,9 +1,13 @@ import ArtalkConfig, { LocalUser } from "~/types/artalk-config" +import Context from '~/types/context' export default class User { + public ctx: Context public data: LocalUser - constructor (conf: ArtalkConfig) { + public constructor(ctx: Context) { + this.ctx = ctx + // 从 localStorage 导入 const localUser = JSON.parse(window.localStorage.getItem('ArtalkUser') || '{}') this.data = { @@ -16,12 +20,25 @@ export default class User { } /** 保存用户到 localStorage 中 */ - public save () { + public update(obj: Partial = {}) { + Object.entries(obj).forEach(([key, value]) => { + this.data[key] = value + }) + window.localStorage.setItem('ArtalkUser', JSON.stringify(this.data)) + this.ctx.trigger('user-changed', this.ctx.user.data) + } + + /** 注销,清除用户登录状态 */ + public logout() { + this.update({ + token: '', + isAdmin: false + }) } /** 是否已填写基本用户信息 */ - public checkHasBasicUserInfo () { + public checkHasBasicUserInfo() { return !!this.data.nick && !!this.data.email } } diff --git a/packages/artalk/src/lib/utils.ts b/packages/artalk/src/lib/utils.ts index 8ee1a54ee..ce99593c5 100644 --- a/packages/artalk/src/lib/utils.ts +++ b/packages/artalk/src/lib/utils.ts @@ -1,7 +1,7 @@ import { marked as libMarked } from 'marked' import insane from 'insane' import hanabi from 'hanabi' -import Context from '../context' +import Context from '~/types/context' export function createElement(htmlStr: string = ''): E { const div = document.createElement('div') @@ -118,7 +118,7 @@ export function onImagesLoaded($container: HTMLElement, event: Function) { } export function getGravatarURL(ctx: Context, emailMD5: string) { - return `${(ctx.conf.gravatar?.mirror || '').replace(/\/$/, '')}/${emailMD5}?d=${encodeURIComponent(ctx.conf.gravatar?.default || '')}&s=80` + return `${(ctx.conf.gravatar.mirror).replace(/\/$/, '')}/${emailMD5}?d=${encodeURIComponent(ctx.conf.gravatar.default)}&s=80` } export function sleep(ms: number) { @@ -142,6 +142,8 @@ export function versionCompare(a: string, b: string) { /** 初始化 marked */ export function initMarked(ctx: Context) { + if (!libMarked) return + const renderer = new libMarked.Renderer() const orgLinkRenderer = renderer.link renderer.link = (href, title, text) => { @@ -186,9 +188,11 @@ export function initMarked(ctx: Context) { /** 解析 markdown */ export function marked(ctx: Context, src: string): string { + const rawContent = ctx.markedInstance?.parse(src) || src + // @link https://github.com/markedjs/marked/discussions/1232 // @link https://gist.github.com/lionel-rowe/bb384465ba4e4c81a9c8dada84167225 - let dest = insane(ctx.markedInstance.parse(src), { + let dest = insane(rawContent, { allowedClasses: {}, allowedSchemes: ['http', 'https', 'mailto'], allowedTags: [ diff --git a/packages/artalk/src/list/index.ts b/packages/artalk/src/list/index.ts new file mode 100644 index 000000000..6b945ef8f --- /dev/null +++ b/packages/artalk/src/list/index.ts @@ -0,0 +1,3 @@ +import List from './list' + +export default List diff --git a/packages/artalk/src/components/list-lite.ts b/packages/artalk/src/list/list-lite.ts similarity index 91% rename from packages/artalk/src/components/list-lite.ts rename to packages/artalk/src/list/list-lite.ts index 8d8e1d274..3ba85f5a4 100644 --- a/packages/artalk/src/components/list-lite.ts +++ b/packages/artalk/src/list/list-lite.ts @@ -1,12 +1,12 @@ -import { ListData, CommentData, NotifyData, ApiVersionData } from '~/types/artalk-data' -import Context from '../context' +import { ListData, CommentData, NotifyData } from '~/types/artalk-data' +import Context from '~/types/context' import Component from '../lib/component' import * as Utils from '../lib/utils' import * as Ui from '../lib/ui' import Api from '../api' -import Comment from './comment' -import Pagination from './pagination' -import ReadMoreBtn from './read-more-btn' +import Comment from '../comment' +import Pagination from '../components/pagination' +import ReadMoreBtn from '../components/read-more-btn' import * as ListNest from './list-nest' import { backendMinVersion } from '../../package.json' @@ -14,7 +14,7 @@ export default class ListLite extends Component { protected $commentsWrap: HTMLElement protected commentList: Comment[] = [] // Note: 无层级结构 + 无须排列 - protected get commentDataList() { return this.commentList.map(c => c.data) } + protected get commentDataList() { return this.commentList.map(c => c.getData()) } protected data?: ListData protected isLoading: boolean = false @@ -184,7 +184,7 @@ export default class ListLite extends Component { this.$el.append(this.readMoreBtn.$el) // 滚动到底部自动加载 - if (this.conf.pagination?.autoLoad) { + if (this.conf.pagination.autoLoad) { // 添加滚动事件监听 const at = this.scrollListenerAt || document if (this.autoLoadScrollEvent) at.removeEventListener('scroll', this.autoLoadScrollEvent) // 解除原有 @@ -296,18 +296,17 @@ export default class ListLite extends Component { protected createComment(cData: CommentData, ctxData?: CommentData[]) { if (!ctxData) ctxData = this.commentDataList - const comment = new Comment(this.ctx, cData) - comment.flatMode = this.flatMode - comment.afterRender = () => { - if (this.renderComment) this.renderComment(comment) - } - comment.onDelete = (c) => { - this.deleteComment(c) - this.refreshUI() - } - - // 子评论查找回复对象 - comment.replyTo = (cData.rid ? ctxData.find(c => c.id === cData.rid) : undefined) + const comment = new Comment(this.ctx, cData, { + isFlatMode: this.flatMode, + afterRender: () => { + if (this.renderComment) this.renderComment(comment) + }, + onDelete: (c: Comment) => { + this.deleteComment(c) + this.refreshUI() + }, + replyTo: (cData.rid ? ctxData.find(c => c.id === cData.rid) : undefined) + }) // 渲染元素 comment.render() @@ -338,7 +337,7 @@ export default class ListLite extends Component { // 显示并播放渐入动画 this.$commentsWrap.appendChild(rootC.getEl()) - rootC.playFadeInAnim() + rootC.getRender().playFadeAnim() // 加载子评论 const that = this @@ -356,7 +355,7 @@ export default class ListLite extends Component { })(rootC, rootNode) // 限高检测 - rootC.checkHeightLimit() + rootC.getRender().checkHeightLimit() }) } @@ -370,11 +369,11 @@ export default class ListLite extends Component { if (cData.visible) { if (insertMode === 'append') this.$commentsWrap.append(comment.getEl()) if (insertMode === 'prepend') this.$commentsWrap.prepend(comment.getEl()) - comment.playFadeInAnim() + comment.getRender().playFadeAnim() } // 平铺评论插入后 · 内容限高检测 - comment.checkHeightLimit() + comment.getRender().checkHeightLimit() return comment } @@ -396,15 +395,15 @@ export default class ListLite extends Component { // 若父评论存在 “子评论部分” 限高,取消限高 comment.getParents().forEach((p) => { - if (p.$children) p.heightLimitRemove(p.$children) + p.getRender().heightLimitRemoveForChildren() }) } } - comment.checkHeightLimit() + comment.getRender().checkHeightLimit() Ui.scrollIntoView(comment.getEl()) // 滚动到可以见 - comment.playFadeInAnim() // 播放评论渐出动画 + comment.getRender().playFadeAnim() // 播放评论渐出动画 } else { // 平铺模式 const comment = this.putCommentFlatMode(commentData, this.commentDataList, 'prepend') @@ -421,7 +420,7 @@ export default class ListLite extends Component { /** 查找评论项 */ protected findComment(id: number): Comment|undefined { - return this.commentList.find(c => c.data.id === id) + return this.commentList.find(c => c.getData().id === id) } /** 删除评论 */ @@ -455,18 +454,18 @@ export default class ListLite extends Component { // 高亮评论 if (this.unreadHighlight === true) { this.commentList.forEach((comment) => { - const notify = this.unread.find(o => o.comment_id === comment.data.id) + const notify = this.unread.find(o => o.comment_id === comment.getID()) if (notify) { - comment.setUnread(true) - comment.setOpenURL(notify.read_link) - comment.openEvt = () => { - this.unread = this.unread.filter(o => o.comment_id !== comment.data.id) // remove + comment.getRender().setUnread(true) + comment.getRender().setOpenAction(() => { + window.open(notify.read_link) + this.unread = this.unread.filter(o => o.comment_id !== comment.getID()) // remove this.ctx.trigger('unread-update', { notifies: this.unread }) - } + }) } else { - comment.setUnread(false) + comment.getRender().setUnread(false) } }) } diff --git a/packages/artalk/src/components/list-nest.ts b/packages/artalk/src/list/list-nest.ts similarity index 100% rename from packages/artalk/src/components/list-nest.ts rename to packages/artalk/src/list/list-nest.ts diff --git a/packages/artalk/src/components/html/list.html b/packages/artalk/src/list/list.html similarity index 91% rename from packages/artalk/src/components/html/list.html rename to packages/artalk/src/list/list.html index 178b4af72..e8099b331 100644 --- a/packages/artalk/src/components/html/list.html +++ b/packages/artalk/src/list/list.html @@ -7,7 +7,7 @@ -
通知中心
+
diff --git a/packages/artalk/src/components/list.ts b/packages/artalk/src/list/list.ts similarity index 96% rename from packages/artalk/src/components/list.ts rename to packages/artalk/src/list/list.ts index dc9ed8c6b..ae80cccb6 100644 --- a/packages/artalk/src/components/list.ts +++ b/packages/artalk/src/list/list.ts @@ -1,11 +1,11 @@ import '../style/list.less' import { ListData } from '~/types/artalk-data' -import Context from '../context' +import Context from '~/types/context' import * as Utils from '../lib/utils' import * as Ui from '../lib/ui' import Api from '../api' -import ListHTML from './html/list.html?raw' +import ListHTML from './list.html?raw' import ListLite from './list-lite' export default class List extends ListLite { @@ -36,8 +36,8 @@ export default class List extends ListLite { this.flatMode = flatMode // 分页模式 - this.pageMode = this.conf.pagination?.readMore ? 'read-more' : 'pagination' - this.pageSize = this.conf.pagination?.pageSize || 20 + this.pageMode = this.conf.pagination.readMore ? 'read-more' : 'pagination' + this.pageSize = this.conf.pagination.pageSize || 20 this.repositionAt = this.$el // 操作按钮 @@ -161,7 +161,7 @@ export default class List extends ListLite { // 若父评论存在 “子评论部分” 限高,取消限高 comment.getParents().forEach((p) => { - if (p.$children) p.heightLimitRemove(p.$children) + p.getRender().heightLimitRemoveForChildren() }) const goTo = () => { diff --git a/packages/artalk/src/style/comment.less b/packages/artalk/src/style/comment.less index 3d03e5d77..9128d0d60 100644 --- a/packages/artalk/src/style/comment.less +++ b/packages/artalk/src/style/comment.less @@ -71,6 +71,7 @@ .atk-item { display: flex; + margin-top: 2px; margin-bottom: 2px; color: var(--at-color-meta); @@ -107,10 +108,11 @@ } .badge() { + display: inline-block; color: var(--at-color-meta); background: var(--at-color-bg-grey); - padding: 0 6px; - line-height: 18px; + padding: 0px 6px; + line-height: 17px; border-radius: 3px; &:not(:last-child) { @@ -118,6 +120,10 @@ } } + .atk-badge-wrap { + margin-right: 6px; + } + .atk-badge { .badge(); color: #fff; @@ -136,6 +142,7 @@ .atk-ua-wrap { @media only screen and (max-width: 768px) { display: block; + margin-top: 5px; } } } diff --git a/packages/artalk/src/style/editor.less b/packages/artalk/src/style/editor.less index 731e87ac0..4e2997702 100644 --- a/packages/artalk/src/style/editor.less +++ b/packages/artalk/src/style/editor.less @@ -100,7 +100,7 @@ } } - & > .atk-plug-wrap { + & > .atk-plug-panel-wrap { position: relative; height: 180px; width: 100%; diff --git a/packages/artalk/types/artalk-config.d.ts b/packages/artalk/types/artalk-config.d.ts index 945175590..2a28601ce 100644 --- a/packages/artalk/types/artalk-config.d.ts +++ b/packages/artalk/types/artalk-config.d.ts @@ -8,99 +8,103 @@ export default interface ArtalkConfig { pageKey: string /** 页面标题 */ - pageTitle?: string + pageTitle: string /** 服务器地址 */ server: string /** 站点名 */ - site?: string + site: string /** 评论框占位字符 */ - placeholder?: string + placeholder: string /** 评论为空时显示字符 */ - noComment?: string + noComment: string /** 发送按钮文字 */ - sendBtn?: string + sendBtn: string /** 评论框旅行(显示在待回复评论后面) */ - editorTravel?: boolean + editorTravel: boolean /** 表情包 */ - emoticons?: object|any[]|string|false + emoticons: object|any[]|string|false /** Gravatar 头像 */ - gravatar?: { + gravatar: { /** 镜像 */ - mirror?: string + mirror: string /** 默认头像(URL or Gravatar Type) */ - default?: string + default: string } /** 分页配置 */ - pagination?: { + pagination: { /** 每次请求获取数量 */ - pageSize?: number + pageSize: number /** 阅读更多模式 */ - readMore?: boolean + readMore: boolean /** 滚动到底部自动加载 */ - autoLoad?: boolean + autoLoad: boolean } /** 内容限高 */ - heightLimit?: { + heightLimit: { /** 评论内容限高 */ - content?: number + content: number /** 子评论区域限高 */ - children?: number + children: number } /** 评论投票按钮 */ - vote?: boolean + vote: boolean /** 评论投票反对按钮 */ - voteDown?: boolean + voteDown: boolean /** PV 元素 Selector */ - pvEl?: string + pvEl: string /** 暗黑模式 */ - darkMode?: boolean|'auto' + darkMode: boolean|'auto' /** 请求超时(单位:秒) */ - reqTimeout?: number + reqTimeout: number /** 平铺模式 */ - flatMode?: boolean|'auto' + flatMode: boolean|'auto' /** 嵌套模式 · 最大层数 */ - nestMax?: number + nestMax: number /** 嵌套模式 · 排序方式 */ - nestSort?: 'DATE_ASC'|'DATE_DESC' + nestSort: 'DATE_ASC'|'DATE_DESC' /** 显示 UA 徽标 */ - uaBadge?: boolean + uaBadge: boolean /** 评论列表排序功能 (显示 Dropdown) */ - listSort?: boolean + listSort: boolean /** 图片上传功能 */ - imgUpload?: boolean + imgUpload: boolean /** 版本检测 */ - versionCheck?: boolean + versionCheck: boolean /** 应用后端配置 */ - useBackendConf?: boolean + useBackendConf: boolean /** 国际化 */ - i18n?: I18n|string + i18n: I18n|string } +/** + * 本地持久化用户数据 + * @note 始终保持一层结构,不支持多层结构 + */ export interface LocalUser { /** 昵称 */ nick: string diff --git a/packages/artalk/types/context.d.ts b/packages/artalk/types/context.d.ts new file mode 100644 index 000000000..74155131f --- /dev/null +++ b/packages/artalk/types/context.d.ts @@ -0,0 +1,22 @@ +import { marked as libMarked } from 'marked' +import ArtalkConfig from './artalk-config' +import { EventPayloadMap, Event, EventScopeType, Handler } from './event' +import { internal as internalLocales, I18n } from '../src/i18n' +import User from '../src/lib/user' + +/** + * Context 接口 + * @desc 面向接口的编程 + */ +export default interface ContextApi { + cid: number + $root: HTMLElement + conf: ArtalkConfig + user: User + on(name: K, handler: Handler, scope?: EventScopeType): void + off(name: K, handler?: Handler, scope?: EventScopeType): void + trigger(name: K, payload?: EventPayloadMap[K], scope?: EventScopeType): void + $t(key: keyof I18n, args?: {[key: string]: string}): string + markedInstance: typeof libMarked + markedReplacers: ((raw: string) => string)[] +} diff --git a/packages/artalk/vite-lite.config.ts b/packages/artalk/vite-lite.config.ts new file mode 100644 index 000000000..fe0595ab2 --- /dev/null +++ b/packages/artalk/vite-lite.config.ts @@ -0,0 +1,18 @@ +import fullVersionConf from './vite.config' +import * as Utils from './src/lib/utils' + +export default Utils.mergeDeep(fullVersionConf, { + build: { + lib: { + fileName: (format) => ((format == "umd") ? 'ArtalkLite.js' : `ArtalkLite.${format}.js`), + }, + rollupOptions: { + external: ['marked'], + output: { + globals: { + marked: 'marked', + }, + } + } + }, +}) diff --git a/packages/artalk/vite.config.ts b/packages/artalk/vite.config.ts index 325f6c312..255af23bb 100644 --- a/packages/artalk/vite.config.ts +++ b/packages/artalk/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ target: 'es2015', outDir: resolve(__dirname, "dist"), minify: 'terser', + emptyOutDir: false, lib: { entry: resolve(__dirname, 'src/main.ts'), name: 'Artalk',