From c6ac4466f05f2cdf99459332d3aeb63983104cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20K=C3=A1rolyi?= Date: Sun, 19 Nov 2023 17:37:25 +0100 Subject: [PATCH] Ditch old popover handler --- .../macros/topic-comment-action-template.html | 13 - frontend/src/ts/topic-group.ts | 29 +- .../src/ts/utils/comment-voting-details.ts | 6 +- frontend/src/ts/utils/new-popover.ts | 306 -------- frontend/src/ts/utils/one-comment.ts | 93 ++- frontend/src/ts/utils/popover.ts | 725 +++++++----------- 6 files changed, 360 insertions(+), 812 deletions(-) delete mode 100644 frontend/src/ts/utils/new-popover.ts diff --git a/backend/forum/base/jinja/base/macros/topic-comment-action-template.html b/backend/forum/base/jinja/base/macros/topic-comment-action-template.html index e59302c..217006a 100644 --- a/backend/forum/base/jinja/base/macros/topic-comment-action-template.html +++ b/backend/forum/base/jinja/base/macros/topic-comment-action-template.html @@ -1,17 +1,4 @@ {% macro topic_comment_action_template() -%} -{% if request.user.is_authenticated -%} -
- - -
-{% endif -%} - {{ _('Show all previous comments') }} diff --git a/frontend/src/ts/topic-group.ts b/frontend/src/ts/topic-group.ts index fb8ae96..a72ae6d 100644 --- a/frontend/src/ts/topic-group.ts +++ b/frontend/src/ts/topic-group.ts @@ -4,9 +4,12 @@ import { unsetButtonLoading } from './utils/common.ts' import { Paginator } from './utils/paginator.ts' -import { add as popoverAdd } from './utils/popover.ts' +import { + type ForumPopoverEl, add as popoverAdd, teardown as popoverTeardown +} from './utils/popover.ts' import { initAllTimeElements } from './utils/time-actualizer.ts' import { add as usernameAdd } from './utils/username.ts' +import { addOnremoveCallback } from './utils/mutation-observer.ts' class LoadArchivedError extends Error { elSvg?: SVGElement @@ -104,8 +107,8 @@ class TopicGroup { throw error } - private getPopoverContent(element: HTMLAnchorElement): string { - const slug = element.dataset.slug + private getPopoverContent(el: ForumPopoverEl): string { + const slug = el.dataset.slug if (slug == null) return '?' const tipTemplate = this.root.querySelector( `.forum-topic-tooltip-template[data-slug="${slug}"]`) @@ -124,17 +127,17 @@ class TopicGroup { const topicLinkElements = this.root.querySelectorAll('.topic-link') for (const node of topicLinkElements) { - popoverAdd(node, { - callbacks: { getContent: this.getPopoverContent.bind(this) }, - html: true, - sanitize: false, - delay: { - show: 250, - hide: 500 - }, - customClass: 'topicgroup-popover-wrapper', - showOnClick: false + popoverAdd(node, { + popoverOpts: { + content: this.getPopoverContent.bind(this), + html: true, + sanitize: false, + delay: { show: 250, hide: 500 }, + customClass: 'topicgroup-popover-wrapper', + container: node.parentElement + } }) + addOnremoveCallback(node, popoverTeardown) } this.wrappers.paginators = this.root.querySelectorAll(this.options.selectors.paginationWrapper) diff --git a/frontend/src/ts/utils/comment-voting-details.ts b/frontend/src/ts/utils/comment-voting-details.ts index 0c0c639..86d1ff1 100644 --- a/frontend/src/ts/utils/comment-voting-details.ts +++ b/frontend/src/ts/utils/comment-voting-details.ts @@ -1,10 +1,8 @@ import { getCookie, getSvgIcon, loaderTemplate } from './common' import { - type ForumPopoverEl, - type ForumPopoverTipEl, - add as popoverAdd, + type ForumPopoverEl, type ForumPopoverTipEl, add as popoverAdd, teardown as popoverTeardown -} from './new-popover.ts' +} from './popover.ts' import { urlTemplates, stringsPassed } from '../topic-comment-listing' import { mdiLinkOff, mdiThumbUp, mdiThumbDown } from '@mdi/js' import { add as usernameAdd } from './username.ts' diff --git a/frontend/src/ts/utils/new-popover.ts b/frontend/src/ts/utils/new-popover.ts deleted file mode 100644 index 98bbe2a..0000000 --- a/frontend/src/ts/utils/new-popover.ts +++ /dev/null @@ -1,306 +0,0 @@ -import Popover from 'bootstrap/js/src/popover.js' -import { addOnremoveCallback } from './mutation-observer' - -export type ForumPopoverEl = ElCls & { - forumPopoverConfig: StoredConfigType - forumPopoverTip?: ForumPopoverTipEl -} - -export type ForumPopoverTipEl = ElCls & { - forumPopoverEl: ForumPopoverEl -} - -type ComputedPopoverOpts = - Omit, 'trigger' | 'content'> & { - delay: { show: number, hide: number } - trigger: 'manual' - content: (el: ForumPopoverEl) => string | Element - } - -type PassedPopoverOpts = - Omit, 'trigger' | 'content'> & { - content?: (el: ForumPopoverEl) => string | Element - } - -interface CallbacksType { - onTipInserted?: (el: ForumPopoverEl) => void - onTipRemoved?: (el: ForumPopoverEl) => void -} - -interface InitConfigType { - popoverOpts?: PassedPopoverOpts - callbacks?: CallbacksType -} - -interface StoredConfigType { - popoverObj: Popover - computedOpts: ComputedPopoverOpts - showTimeoutId?: ReturnType - hideTimeoutId?: ReturnType - isShown: boolean - isPointerenteredElement: boolean - isPointerEnteredTip: boolean - isFocusedElement: boolean - isFocusedTip: boolean - callbacks?: CallbacksType - origGetContent?: (el: ForumPopoverEl) => string | Element - cachedContent?: string | Element -} - -function onFocusinTip( - this: ForumPopoverTipEl -): void { - console.debug('onFocusinTip', document.activeElement) - this.forumPopoverEl.forumPopoverConfig.isFocusedTip = true - showPopover(this.forumPopoverEl.forumPopoverConfig) -} - -function onFocusoutTip( - this: ForumPopoverTipEl -): void { - console.debug('onFocusoutTip', document.activeElement) - this.forumPopoverEl.forumPopoverConfig.isFocusedTip = false - hidePopover(this.forumPopoverEl.forumPopoverConfig) -} - -function onTipRemoved( - elTip: ForumPopoverTipEl -): void { - // console.debug('onTipRemoved', elTip) - elTip.removeEventListener('pointerenter', onPointerEnterTip) - elTip.removeEventListener('pointerleave', onPointerLeaveTip) - elTip.removeEventListener('focusin', onFocusinTip) - elTip.removeEventListener('focusout', onFocusoutTip) - const config = elTip.forumPopoverEl.forumPopoverConfig - if (config.callbacks?.onTipRemoved) { - config.callbacks.onTipRemoved(elTip.forumPopoverEl) - } - config.isPointerenteredElement = false - config.isFocusedTip = false - delete elTip.forumPopoverEl.forumPopoverTip - // @ts-expect-error Allowed because element will get destroyed - delete elTip.forumPopoverEl -} - -function onTipInserted( - this: ForumPopoverEl -): void { - const config = this.forumPopoverConfig - // @ts-expect-error `tip` exists, it's just not in the type definition - const elTip = >config.popoverObj.tip - elTip.forumPopoverEl = this - this.forumPopoverTip = elTip - if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this) - elTip.addEventListener('pointerenter', onPointerEnterTip) - elTip.addEventListener('pointerleave', onPointerLeaveTip) - elTip.addEventListener('focusin', onFocusinTip) - elTip.addEventListener('focusout', onFocusoutTip) - addOnremoveCallback(elTip, onTipRemoved) -} - -function onPointerEnterTip( - this: ForumPopoverTipEl -): void { - // console.debug('onPointerEnterTip', this, ev) - const config = this.forumPopoverEl.forumPopoverConfig - config.isPointerEnteredTip = true - showPopover(config) -} - -function onPointerLeaveTip( - this: ForumPopoverTipEl -): void { - // console.debug('onPointerLeaveTip', this) - const config = this.forumPopoverEl.forumPopoverConfig - config.isPointerEnteredTip = false - hidePopover(config) -} - -function onFocusinElement( - this: ForumPopoverEl -): void { - // console.debug('onFocusinElement', document.activeElement) - const config = this.forumPopoverConfig - config.isFocusedElement = true - showPopover(config) -} - -function onFocusoutElement( - this: ForumPopoverEl -): void { - // console.debug('onFocusoutElement', document.activeElement) - const config = this.forumPopoverConfig - config.isFocusedElement = false - hidePopover(config) -} - -function onPointerEnterElement( - this: ForumPopoverEl -): void { - // console.debug('onPointerenterElement') - const config = this.forumPopoverConfig - config.isPointerenteredElement = true - showPopover(config) -} - -function onPointerLeaveElement( - this: ForumPopoverEl -): void { - // console.debug('onPointerleaveElement') - const config = this.forumPopoverConfig - config.isPointerenteredElement = false - hidePopover(config) -} - -function onPopoverHidden( - this: ForumPopoverEl -): void { - const config = this.forumPopoverConfig - config.isShown = config.isFocusedTip = config.isPointerEnteredTip = false -} - -// The content() function is executed twice (probable bootstrap bug): -// use this function as an umbrella to only call it once -function onGetContent( - el: ForumPopoverEl -): string | Element { - if (el.forumPopoverConfig.cachedContent == null) { - el.forumPopoverConfig.cachedContent = el.forumPopoverConfig.origGetContent - ? el.forumPopoverConfig.origGetContent(el) - : '' - } - return el.forumPopoverConfig.cachedContent -} - -function isForumPopoverEl( - el: ElCls | ForumPopoverEl -): el is ForumPopoverEl { - return Object.hasOwn(el, 'forumPopoverConfig') -} - -function clearHideTimeoutId( - config: StoredConfigType -): void { - if (config.hideTimeoutId == null) return - clearTimeout(config.hideTimeoutId) - delete config.hideTimeoutId -} - -function clearShowTimeoutId( - config: StoredConfigType -): void { - if (config.showTimeoutId == null) return - clearTimeout(config.showTimeoutId) - delete config.showTimeoutId -} - -function showPopoverImmediately( - config: StoredConfigType -): void { - delete config.showTimeoutId - if (config.isShown) return - if ( - !config.isFocusedElement && - !config.isPointerenteredElement - ) return - config.isShown = true - config.popoverObj.show() - delete config.cachedContent -} - -function showPopover( - config: StoredConfigType -): void { - // console.debug('showPopover', config) - clearHideTimeoutId(config) - if (config.showTimeoutId != null) return - config.showTimeoutId = - setTimeout(showPopoverImmediately, config.computedOpts.delay.show, config) -} - -function hidePopoverImmediately( - config: StoredConfigType -): void { - delete config.hideTimeoutId - if ( - !config.isShown || - config.isPointerenteredElement || - config.isPointerEnteredTip || - config.isFocusedElement || - config.isFocusedTip - ) return - config.popoverObj.hide() -} - -function hidePopover( - config: StoredConfigType -): void { - // console.debug('hidePopover', config) - clearShowTimeoutId(config) - if (config.hideTimeoutId != null) return - config.hideTimeoutId = - setTimeout(hidePopoverImmediately, config.computedOpts.delay.hide, config) -} - -function computeOpts( - origOpts?: PassedPopoverOpts -): ComputedPopoverOpts { - const computedOpts: ComputedPopoverOpts = { - ...origOpts, - delay: { show: 250, hide: 0 }, - trigger: 'manual', - content: onGetContent - } - if (origOpts?.delay != null) { - computedOpts.delay = { - show: typeof origOpts.delay === 'number' - ? origOpts.delay - : origOpts.delay.show, - hide: typeof origOpts.delay === 'number' - ? origOpts.delay - : origOpts.delay.hide - } - } - return computedOpts -} - -export function add( - el: ElCls, config: InitConfigType -): void { - if (isForumPopoverEl(el)) return - const computedOpts = computeOpts(config.popoverOpts) - const popoverConfig: StoredConfigType = { - popoverObj: new Popover(el, >computedOpts), - computedOpts, - isShown: false, - isPointerenteredElement: false, - isPointerEnteredTip: false, - isFocusedElement: false, - isFocusedTip: false, - callbacks: config.callbacks, - origGetContent: config.popoverOpts?.content - } - Object.defineProperty(el, 'forumPopoverConfig', { - value: popoverConfig, - configurable: true - }) - el.addEventListener('inserted.bs.popover', onTipInserted) - el.addEventListener('hidden.bs.popover', onPopoverHidden) - el.addEventListener('focusin', onFocusinElement) - el.addEventListener('focusout', onFocusoutElement) - el.addEventListener('pointerenter', onPointerEnterElement) - el.addEventListener('pointerleave', onPointerLeaveElement) -} - -export function teardown(el: ElCls): void { - if (!isForumPopoverEl(el)) return - el.forumPopoverConfig.popoverObj.dispose() - el.removeEventListener('inserted.bs.popover', onTipInserted) - el.removeEventListener('hidden.bs.popover', onPopoverHidden) - el.removeEventListener('focusin', onFocusinElement) - el.removeEventListener('focusout', onFocusoutElement) - el.removeEventListener('pointerenter', onPointerEnterElement) - el.removeEventListener('pointerleave', onPointerLeaveElement) - // @ts-expect-error Popover is being destroyed so it's okay - delete el.forumPopoverConfig -} diff --git a/frontend/src/ts/utils/one-comment.ts b/frontend/src/ts/utils/one-comment.ts index eb4c46e..2536826 100644 --- a/frontend/src/ts/utils/one-comment.ts +++ b/frontend/src/ts/utils/one-comment.ts @@ -1,6 +1,8 @@ import { getScrollTop, getSvgIcon, extractTemplateContent } from './common.ts' import Tooltip from 'bootstrap/js/src/tooltip.js' -import { add as popoverAdd } from './popover.ts' +import { + type ForumPopoverEl, add as popoverAdd, teardown as popoverTeardown +} from './popover.ts' import { add as toastAdd } from './toast.ts' import { add as usernameAdd } from './username.ts' import { add as timeActualizerAdd } from './time-actualizer.ts' @@ -84,29 +86,46 @@ export class OneComment { toastAdd({ message: stringsPassed.noClipboardError }) } - private onPopoverDomInsertedCommentAction(el: HTMLButtonElement): void { - } - - private onPopoverDomRemovedCommentAction(el: HTMLButtonElement): void { - } - - private onTeardownActionsPopoverContent(el: HTMLButtonElement): void { + private onPopoverTipInsertedCommentAction( + el: ForumPopoverEl + ): void { + if (!this.actionsPopoverReferences) return if (this.actionsPopoverReferences?.elExpandCommentsDown) { Tooltip.getOrCreateInstance( - this.actionsPopoverReferences.elExpandCommentsDown).dispose() + this.actionsPopoverReferences.elExpandCommentsDown) } if (this.actionsPopoverReferences?.elExpandCommentsUp) { Tooltip.getOrCreateInstance( - this.actionsPopoverReferences.elExpandCommentsUp).dispose() + this.actionsPopoverReferences.elExpandCommentsUp) } if (this.actionsPopoverReferences?.elExpandCommentsUpRecursive) { Tooltip.getOrCreateInstance( - this.actionsPopoverReferences.elExpandCommentsUpRecursive).dispose() + this.actionsPopoverReferences.elExpandCommentsUpRecursive) } if (this.actionsPopoverReferences?.elExpandCommentsInThread) { Tooltip.getOrCreateInstance( + this.actionsPopoverReferences.elExpandCommentsInThread) + } + delete this.actionsPopoverReferences + } + + private onPopoverTipRemovedCommentAction(el: HTMLButtonElement): void { + if (this.actionsPopoverReferences?.elExpandCommentsDown) { + Tooltip.getInstance( + this.actionsPopoverReferences.elExpandCommentsDown)?.dispose() + } + if (this.actionsPopoverReferences?.elExpandCommentsUp) { + Tooltip.getInstance( + this.actionsPopoverReferences.elExpandCommentsUp)?.dispose() + } + if (this.actionsPopoverReferences?.elExpandCommentsUpRecursive) { + Tooltip.getInstance( + this.actionsPopoverReferences.elExpandCommentsUpRecursive)?.dispose() + } + if (this.actionsPopoverReferences?.elExpandCommentsInThread) { + Tooltip.getInstance( this.actionsPopoverReferences.elExpandCommentsInThread - ).dispose() + )?.dispose() } delete this.actionsPopoverReferences } @@ -115,8 +134,7 @@ export class OneComment { const commentLink = event.currentTarget if (!(commentLink instanceof HTMLAnchorElement)) return event.preventDefault() - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!navigator.clipboard) { + if (navigator.clipboard == null) { this.onErrorClipboardCopy() return } @@ -201,7 +219,9 @@ export class OneComment { } } - private initializePopoverOfCommentActions(): HTMLDivElement { + private getContentCommentActions( + el: ForumPopoverEl + ): HTMLElement { const elRoot = elCommentActionTemplate.cloneNode(true) this.actionsPopoverReferences = { elContentRoot: elRoot @@ -212,7 +232,6 @@ export class OneComment { if (this.elPrevCommentLink) { elAnchorEcd.getElementsByClassName('icon')[0] .replaceWith(getSvgIcon(mdiArrowLeft)) - Tooltip.getOrCreateInstance(elAnchorEcd) this.actionsPopoverReferences.elExpandCommentsDown = elAnchorEcd } else elAnchorEcd.remove() const elAnchorEcu = @@ -224,10 +243,8 @@ export class OneComment { if (this.oneReplies.size) { elAnchorEcu.getElementsByClassName('icon')[0] .replaceWith(getSvgIcon(mdiArrowRight)) - Tooltip.getOrCreateInstance(elAnchorEcu) elAnchorEcur.getElementsByClassName('icon')[0] .replaceWith(getSvgIcon(myArrowRightDouble)) - Tooltip.getOrCreateInstance(elAnchorEcur) this.actionsPopoverReferences.elExpandCommentsUp = elAnchorEcu this.actionsPopoverReferences.elExpandCommentsUpRecursive = elAnchorEcur } else { @@ -240,7 +257,6 @@ export class OneComment { if (this.oneReplies.size || this.elPrevCommentLink) { elAnchorEcit.getElementsByClassName('icon')[0] .replaceWith(getSvgIcon(mdiArrowLeftRight)) - Tooltip.getOrCreateInstance(elAnchorEcit) this.actionsPopoverReferences.elExpandCommentsInThread = elAnchorEcit } else elAnchorEcit.remove() this.initPopoverLinks() @@ -254,30 +270,24 @@ export class OneComment { } this.elBtnCommentActions.replaceChildren(elSvgCog.cloneNode(true)) popoverAdd(this.elBtnCommentActions, { + popoverOpts: { + content: el => { + return this.getContentCommentActions(el) + }, + html: true, + delay: { show: 500, hide: 500 }, + sanitize: false, + customClass: 'forum-topic-comment-action-popovercontent', + container: this.elBtnCommentActions.parentElement + }, callbacks: { - getContent: (el) => { - return this.initializePopoverOfCommentActions() + onTipInserted: el => { + this.onPopoverTipInsertedCommentAction(el) }, - onDomInsertedTip: (el: HTMLButtonElement) => { - this.onPopoverDomInsertedCommentAction(el) - }, - onDomRemovedTip: (el) => { - this.onPopoverDomRemovedCommentAction(el) - }, - onInvalidateContent: (el: HTMLButtonElement) => { - this.onTeardownActionsPopoverContent(el) - }, - onTeardown: (el: HTMLButtonElement) => { - this.onTeardownActionsPopoverContent(el) + onTipRemoved: el => { + this.onPopoverTipRemovedCommentAction(el) } - }, - html: true, - delay: { - show: 500, hide: 500 - }, - sanitize: false, - customClass: 'forum-topic-comment-action-popovercontent', - showOnClick: true + } }) } @@ -327,6 +337,9 @@ export class OneComment { for (const el of this.oneReplies.values()) this.teardownOneReply(el) this.oneReplies.clear() this.teardownVotingValue() + if (this.elBtnCommentActions.parentElement) { + popoverTeardown(this.elBtnCommentActions) + } } flashHighlight(): void { diff --git a/frontend/src/ts/utils/popover.ts b/frontend/src/ts/utils/popover.ts index 4265a01..9e6001d 100644 --- a/frontend/src/ts/utils/popover.ts +++ b/frontend/src/ts/utils/popover.ts @@ -1,453 +1,306 @@ import Popover from 'bootstrap/js/src/popover.js' -import { addOnremoveCallback } from './mutation-observer.ts' +import { addOnremoveCallback } from './mutation-observer' -interface PopoverInitOptions { - callbacks: { - getTitle?: (element: WatchedType) => string | HTMLElement - getContent: (element: WatchedType) => string | HTMLElement - onDomInsertedTip?: (element: WatchedType) => void - onDomRemovedTip?: (element: WatchedType) => void - onInvalidateContent?: (element: WatchedType) => void - onInvalidateTitle?: (element: WatchedType) => void - onTeardown?: (element: WatchedType) => void +export type ForumPopoverEl = ElCls & { + forumPopoverConfig: StoredConfigType + forumPopoverTip?: ForumPopoverTipEl +} + +export type ForumPopoverTipEl = ElCls & { + forumPopoverEl: ForumPopoverEl +} + +type ComputedPopoverOpts = + Omit, 'trigger' | 'content'> & { + delay: { show: number, hide: number } + trigger: 'manual' + content: (el: ForumPopoverEl) => string | Element } - html: Popover.Options['html'] - delay: { - show: number - hide: number + +type PassedPopoverOpts = + Omit, 'trigger' | 'content'> & { + content?: (el: ForumPopoverEl) => string | Element } - showOnClick: boolean - sanitize: Popover.Options['sanitize'] - customClass?: Popover.Options['customClass'] - initialContent?: string + +interface CallbacksType { + onTipInserted?: (el: ForumPopoverEl) => void + onTipRemoved?: (el: ForumPopoverEl) => void } -export interface GetFunctionsReturnType { - invalidateTitle: () => void - invalidateContent: () => void - isShown: () => boolean +interface InitConfigType { + popoverOpts?: PassedPopoverOpts + callbacks?: CallbacksType } -interface BoundFunctions { - mouseEnterPopover: (event: Event) => void - mouseLeavePopover: (event: Event) => void - clickElement: (event: Event) => void - mouseEnterElement: (event: Event) => void - mouseLeaveElement: (event: Event) => void - focusInPopover: (event: Event) => void - focusOutPopover: (event: Event) => void - focusInElement: (event: Event) => void - focusOutElement: (event: Event) => void - popoverInserted: (event: Event) => void -} - -interface ObjConfig { - popoverObj?: Popover - cachedTitle?: string | HTMLElement - cachedContent?: string | HTMLElement - isShown: boolean - isTipHovered: boolean - isPopoverFocused: boolean - isElementHovered: boolean - // isFocused is for keyboard focus - isElementFocused: boolean +interface StoredConfigType { + popoverObj: Popover + computedOpts: ComputedPopoverOpts showTimeoutId?: ReturnType hideTimeoutId?: ReturnType - delay: { - show: number - hide: number - } - elPopover?: HTMLDivElement - boundFunctions: BoundFunctions - exportedFunctions: GetFunctionsReturnType - callbacks: { - getTitle?: (element: WatchedType) => string | HTMLElement - getContent: (element: WatchedType) => string | HTMLElement - onDomInsertedTip?: (el: WatchedType) => void - onDomRemovedTip?: (el: WatchedType) => void - onInvalidateTitle?: (el: WatchedType) => void - onInvalidateContent?: (el: WatchedType) => void - onTeardown?: (el: WatchedType) => void - } - showOnClick: boolean + isShown: boolean + isPointerenteredElement: boolean + isPointerEnteredTip: boolean + isFocusedElement: boolean + isFocusedTip: boolean + callbacks?: CallbacksType + origGetContent?: (el: ForumPopoverEl) => string | Element + cachedContent?: string | Element } -class PopoverHandler { - private readonly instances = - new Map>() - - private onPopoverInserted( - event: Event - ): void { - const el = event.currentTarget - const config = this.getConfigForElement(el) - config.elPopover = config.popoverObj?.tip - const parentNode = el.parentNode - if (!parentNode) return - config.elPopover.addEventListener( - 'mouseenter', config.boundFunctions.mouseEnterPopover) - config.elPopover.addEventListener( - 'mouseleave', config.boundFunctions.mouseLeavePopover) - config.elPopover.addEventListener( - 'focusin', config.boundFunctions.focusInPopover) - config.elPopover.addEventListener( - 'focusout', config.boundFunctions.focusOutPopover) - const removeCallback = (elPopover: HTMLDivElement): void => { - this.onPopoverRemoved(el, elPopover) - } - addOnremoveCallback(config.elPopover, removeCallback) - if (config.callbacks.onDomInsertedTip) { - config.callbacks.onDomInsertedTip(el) - } - } - - private onPopoverRemoved( - el: WatchedType, elPopover: HTMLDivElement - ): void { - const config = this.getConfigForElement(el) - elPopover.removeEventListener( - 'mouseenter', config.boundFunctions.mouseEnterPopover) - elPopover.removeEventListener( - 'mouseleave', config.boundFunctions.mouseLeavePopover) - elPopover.removeEventListener( - 'focusin', config.boundFunctions.focusInPopover) - elPopover.removeEventListener( - 'focusout', config.boundFunctions.focusOutPopover) - delete config.elPopover - if (config.callbacks.onDomRemovedTip) config.callbacks.onDomRemovedTip(el) - } - - private onMouseenterPopover( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - config.isTipHovered = true - } - - private onMouseleavePopover( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - config.isTipHovered = false - this.hidePopover(config) - } - - private onClickElement(event: Event): void { - const config = this.getConfigForEvent(event) - config.isElementHovered = true - this.showPopoverImmediately(config) - } - - private onMouseenterElement(event: Event): void { - const config = this.getConfigForEvent(event) - config.isElementHovered = true - this.showPopover(config) - } - - private onMouseleaveElement(event: Event): void { - const config = this.getConfigForEvent(event) - config.isElementHovered = false - this.hidePopover(config) - } - - private onFocusinPopover( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - config.isPopoverFocused = true - this.showPopover(config) - } - - private onFocusoutPopover( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - config.isPopoverFocused = false - this.hidePopover(config) - } - - private onFocusinElement(event: Event): void { - const config = this.getConfigForEvent(event) - config.isElementFocused = true - this.showPopover(config) - } - - private onFocusoutElement(event: Event): void { - const config = this.getConfigForEvent(event) - config.isElementFocused = false - this.hidePopover(config) - } - - private getConfigForElement( - element: WatchedType - ): ObjConfig { - const config: ObjConfig | undefined = - this.instances.get(element) - if (!config) { - throw Error(`PopoverHandler: No instance for ${element.outerHTML}`) - } - return config - } - - private getConfigForEvent( - event: Event - ): ObjConfig { - const element = event.currentTarget - return this.getConfigForElement(element) - } - - private clearHideTimeoutId( - config: ObjConfig - ): void { - if (config.hideTimeoutId == null) return - clearTimeout(config.hideTimeoutId) - delete config.hideTimeoutId - } - - private clearShowTimeoutId( - config: ObjConfig - ): void { - if (config.showTimeoutId == null) return - clearTimeout(config.showTimeoutId) - delete config.showTimeoutId - } - - private showPopoverImmediately( - config: ObjConfig - ): void { - delete config.showTimeoutId - if (config.isShown) return - if ( - !config.isElementFocused && - !config.isElementHovered - ) return - config.popoverObj?.show() - config.isShown = true - } - - private showPopover(config: ObjConfig): void { - this.clearHideTimeoutId(config) - if (config.showTimeoutId != null) return - config.showTimeoutId = setTimeout(() => { - this.showPopoverImmediately(config) - }, config.delay.show) - } - - private hidePopover(config: ObjConfig): void { - this.clearShowTimeoutId(config) - if (config.hideTimeoutId != null) return - config.hideTimeoutId = setTimeout(() => { - delete config.hideTimeoutId - if ( - !config.isShown || - config.isTipHovered || - config.isElementFocused || - config.isElementHovered || - config.isPopoverFocused - ) return - config.popoverObj?.hide() - delete config.elPopover - config.isShown = false - }, config.delay.hide) - } - - private invalidateTitle( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - delete config.cachedTitle - if (config.callbacks.onInvalidateTitle) { - config.callbacks.onInvalidateTitle(element) - } - if (!config.isShown || !config.callbacks.getTitle) return - config.popoverObj?.setContent({ - '.popover-header': config.callbacks.getTitle(element) - }) - } - - private invalidateContent( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - delete config.cachedContent - console.debug('invalidateContent', config.isShown) - if (config.callbacks.onInvalidateContent) { - config.callbacks.onInvalidateContent(element) - } - if (!config.isShown) return - config.popoverObj?.setContent({ - '.popover-body': config.callbacks.getContent(element) - }) - } - - private teardown( - element: WatchedType - ): void { - const config = this.getConfigForElement(element) - element.removeEventListener( - 'mouseenter', config.boundFunctions.mouseEnterElement) - element.removeEventListener( - 'mouseleave', config.boundFunctions.mouseLeaveElement) - if (config.showOnClick) { - element.removeEventListener( - 'click', config.boundFunctions.clickElement) - } - element.removeEventListener( - 'focusin', config.boundFunctions.focusInElement) - element.removeEventListener( - 'focusout', config.boundFunctions.focusOutElement) - element.removeEventListener( - 'inserted.bs.popover', config.boundFunctions.popoverInserted) - if (config.callbacks.onTeardown) config.callbacks.onTeardown(element) - config.popoverObj?.dispose() - delete config.popoverObj - this.clearHideTimeoutId(config) - this.clearShowTimeoutId(config) - this.instances.delete(element) - } - - private initReturnedFunctions( - element: WatchedType - ): GetFunctionsReturnType { - return { - invalidateTitle: (): void => { - this.invalidateTitle(element) - }, - invalidateContent: (): void => { - this.invalidateContent(element) - }, - isShown: (): boolean => { - const config = this.getConfigForElement(element) - return config.isShown - } - } - } - - private initBoundFunctions( - element: WatchedType - ): BoundFunctions { - return { - mouseEnterPopover: (): void => { this.onMouseenterPopover(element) }, - mouseLeavePopover: (): void => { this.onMouseleavePopover(element) }, - clickElement: this.onClickElement.bind(this), - mouseEnterElement: this.onMouseenterElement.bind(this), - mouseLeaveElement: this.onMouseleaveElement.bind(this), - focusInPopover: () => { this.onFocusinPopover(element) }, - focusOutPopover: () => { this.onFocusoutPopover(element) }, - focusInElement: this.onFocusinElement.bind(this), - focusOutElement: this.onFocusoutElement.bind(this), - popoverInserted: this.onPopoverInserted.bind(this), - } - } - - private getTitle( - element: WatchedType, - cbGetTitle: (element: WatchedType) => string | HTMLElement - ): string | HTMLElement { - const objConfig = this.getConfigForElement(element) - if (objConfig.cachedTitle != null) return objConfig.cachedTitle - const content = objConfig.cachedTitle = cbGetTitle(element) - return content - } - - private getContent( - element: WatchedType - ): string | HTMLElement { - const objConfig = this.getConfigForElement(element) - if (objConfig.cachedContent != null) return objConfig.cachedContent - const content = objConfig.cachedContent = - objConfig.callbacks.getContent(element) - return content - } - - private initPopoverObj( - element: WatchedType, parentNode: Element, - options: PopoverInitOptions - ): Popover { - const popoverOpts: Partial = { - container: parentNode, - content: () => { - return this.getContent(element) - }, - placement: 'auto', - trigger: 'manual', - html: options.html, - sanitize: options.sanitize, - customClass: options.customClass ?? '', - delay: options.delay - } - if (options.callbacks.getTitle) { - const cbGetTitle = options.callbacks.getTitle - popoverOpts.title = () => { - return this.getTitle(element, cbGetTitle) - } - } - return new Popover(element, popoverOpts) - } - - getFunctions( - element: WatchedType - ): GetFunctionsReturnType { - return this.getConfigForElement(element).exportedFunctions - } - - setup( - element: WatchedType, - options: PopoverInitOptions - ): void { - const parentNode = element.parentNode - if (!(parentNode instanceof HTMLElement)) { - throw Error( - `PopoverHandler.add: parentNode of ${element.outerHTML} must be ` + - 'HTMLElement') - } - const popoverObj = this.initPopoverObj(element, parentNode, options) - const boundFunctions = this.initBoundFunctions(element) - const objConfig: ObjConfig = { - popoverObj, - cachedContent: options.initialContent, - isShown: false, - isTipHovered: false, - isPopoverFocused: false, - isElementHovered: false, - isElementFocused: false, - delay: options.delay, - exportedFunctions: this.initReturnedFunctions(element), - boundFunctions, - callbacks: { - getTitle: options.callbacks.getTitle, - getContent: options.callbacks.getContent, - onDomInsertedTip: options.callbacks.onDomInsertedTip, - onDomRemovedTip: options.callbacks.onDomRemovedTip, - onInvalidateTitle: options.callbacks.onInvalidateTitle, - onInvalidateContent: options.callbacks.onInvalidateContent, - onTeardown: options.callbacks.onTeardown - }, - showOnClick: options.showOnClick - } - this.instances.set(element, >objConfig) - element.addEventListener('mouseenter', boundFunctions.mouseEnterElement) - element.addEventListener('mouseleave', boundFunctions.mouseLeaveElement) - if (options.showOnClick) { - element.addEventListener('click', boundFunctions.clickElement) - } - element.addEventListener('focusin', boundFunctions.focusInElement) - element.addEventListener('focusout', boundFunctions.focusOutElement) - element.addEventListener( - 'inserted.bs.popover', boundFunctions.popoverInserted) - addOnremoveCallback(element, this.teardown.bind(this)) - } +function onFocusinTip( + this: ForumPopoverTipEl +): void { + // console.debug('onFocusinTip', document.activeElement) + this.forumPopoverEl.forumPopoverConfig.isFocusedTip = true + showPopover(this.forumPopoverEl.forumPopoverConfig) } -const popoverHandler = new PopoverHandler() - -export function add( - element: WatchedType, - options: PopoverInitOptions): void { - popoverHandler.setup(element, options) +function onFocusoutTip( + this: ForumPopoverTipEl +): void { + // console.debug('onFocusoutTip', document.activeElement) + this.forumPopoverEl.forumPopoverConfig.isFocusedTip = false + hidePopover(this.forumPopoverEl.forumPopoverConfig) } -export function getFunctions( - element: WatchedType): GetFunctionsReturnType { - return popoverHandler.getFunctions(element) +function onTipRemoved( + elTip: ForumPopoverTipEl +): void { + // console.debug('onTipRemoved', elTip) + elTip.removeEventListener('pointerenter', onPointerEnterTip) + elTip.removeEventListener('pointerleave', onPointerLeaveTip) + elTip.removeEventListener('focusin', onFocusinTip) + elTip.removeEventListener('focusout', onFocusoutTip) + const config = elTip.forumPopoverEl.forumPopoverConfig + if (config.callbacks?.onTipRemoved) { + config.callbacks.onTipRemoved(elTip.forumPopoverEl) + } + config.isPointerenteredElement = false + config.isFocusedTip = false + delete elTip.forumPopoverEl.forumPopoverTip + // @ts-expect-error Allowed because element will get destroyed + delete elTip.forumPopoverEl +} + +function onTipInserted( + this: ForumPopoverEl +): void { + const config = this.forumPopoverConfig + // @ts-expect-error `tip` exists, it's just not in the type definition + const elTip = >config.popoverObj.tip + elTip.forumPopoverEl = this + this.forumPopoverTip = elTip + if (config.callbacks?.onTipInserted) config.callbacks.onTipInserted(this) + elTip.addEventListener('pointerenter', onPointerEnterTip) + elTip.addEventListener('pointerleave', onPointerLeaveTip) + elTip.addEventListener('focusin', onFocusinTip) + elTip.addEventListener('focusout', onFocusoutTip) + addOnremoveCallback(elTip, onTipRemoved) +} + +function onPointerEnterTip( + this: ForumPopoverTipEl +): void { + // console.debug('onPointerEnterTip', this, ev) + const config = this.forumPopoverEl.forumPopoverConfig + config.isPointerEnteredTip = true + showPopover(config) +} + +function onPointerLeaveTip( + this: ForumPopoverTipEl +): void { + // console.debug('onPointerLeaveTip', this) + const config = this.forumPopoverEl.forumPopoverConfig + config.isPointerEnteredTip = false + hidePopover(config) +} + +function onFocusinElement( + this: ForumPopoverEl +): void { + // console.debug('onFocusinElement', document.activeElement) + const config = this.forumPopoverConfig + config.isFocusedElement = true + showPopover(config) +} + +function onFocusoutElement( + this: ForumPopoverEl +): void { + // console.debug('onFocusoutElement', document.activeElement) + const config = this.forumPopoverConfig + config.isFocusedElement = false + hidePopover(config) +} + +function onPointerEnterElement( + this: ForumPopoverEl +): void { + // console.debug('onPointerenterElement') + const config = this.forumPopoverConfig + config.isPointerenteredElement = true + showPopover(config) +} + +function onPointerLeaveElement( + this: ForumPopoverEl +): void { + // console.debug('onPointerleaveElement') + const config = this.forumPopoverConfig + config.isPointerenteredElement = false + hidePopover(config) +} + +function onPopoverHidden( + this: ForumPopoverEl +): void { + const config = this.forumPopoverConfig + config.isShown = config.isFocusedTip = config.isPointerEnteredTip = false +} + +// The content() function is executed twice (probable bootstrap bug): +// use this function as an umbrella to only call it once +function onGetContent( + el: ForumPopoverEl +): string | Element { + if (el.forumPopoverConfig.cachedContent == null) { + el.forumPopoverConfig.cachedContent = el.forumPopoverConfig.origGetContent + ? el.forumPopoverConfig.origGetContent(el) + : '' + } + return el.forumPopoverConfig.cachedContent +} + +function isForumPopoverEl( + el: ElCls | ForumPopoverEl +): el is ForumPopoverEl { + return Object.hasOwn(el, 'forumPopoverConfig') +} + +function clearHideTimeoutId( + config: StoredConfigType +): void { + if (config.hideTimeoutId == null) return + clearTimeout(config.hideTimeoutId) + delete config.hideTimeoutId +} + +function clearShowTimeoutId( + config: StoredConfigType +): void { + if (config.showTimeoutId == null) return + clearTimeout(config.showTimeoutId) + delete config.showTimeoutId +} + +function showPopoverImmediately( + config: StoredConfigType +): void { + delete config.showTimeoutId + if (config.isShown) return + if ( + !config.isFocusedElement && + !config.isPointerenteredElement + ) return + config.isShown = true + config.popoverObj.show() + delete config.cachedContent +} + +function showPopover( + config: StoredConfigType +): void { + // console.debug('showPopover', config) + clearHideTimeoutId(config) + if (config.showTimeoutId != null) return + config.showTimeoutId = + setTimeout(showPopoverImmediately, config.computedOpts.delay.show, config) +} + +function hidePopoverImmediately( + config: StoredConfigType +): void { + delete config.hideTimeoutId + if ( + !config.isShown || + config.isPointerenteredElement || + config.isPointerEnteredTip || + config.isFocusedElement || + config.isFocusedTip + ) return + config.popoverObj.hide() +} + +function hidePopover( + config: StoredConfigType +): void { + // console.debug('hidePopover', config) + clearShowTimeoutId(config) + if (config.hideTimeoutId != null) return + config.hideTimeoutId = + setTimeout(hidePopoverImmediately, config.computedOpts.delay.hide, config) +} + +function computeOpts( + origOpts?: PassedPopoverOpts +): ComputedPopoverOpts { + const computedOpts: ComputedPopoverOpts = { + ...origOpts, + delay: { show: 250, hide: 0 }, + trigger: 'manual', + content: onGetContent + } + if (origOpts?.delay != null) { + computedOpts.delay = { + show: typeof origOpts.delay === 'number' + ? origOpts.delay + : origOpts.delay.show, + hide: typeof origOpts.delay === 'number' + ? origOpts.delay + : origOpts.delay.hide + } + } + return computedOpts +} + +export function add( + el: ElCls, config: InitConfigType +): void { + if (isForumPopoverEl(el)) return + const computedOpts = computeOpts(config.popoverOpts) + const popoverConfig: StoredConfigType = { + popoverObj: new Popover(el, >computedOpts), + computedOpts, + isShown: false, + isPointerenteredElement: false, + isPointerEnteredTip: false, + isFocusedElement: false, + isFocusedTip: false, + callbacks: config.callbacks, + origGetContent: config.popoverOpts?.content + } + Object.defineProperty(el, 'forumPopoverConfig', { + value: popoverConfig, + configurable: true + }) + el.addEventListener('inserted.bs.popover', onTipInserted) + el.addEventListener('hidden.bs.popover', onPopoverHidden) + el.addEventListener('focusin', onFocusinElement) + el.addEventListener('focusout', onFocusoutElement) + el.addEventListener('pointerenter', onPointerEnterElement) + el.addEventListener('pointerleave', onPointerLeaveElement) +} + +export function teardown(el: ElCls): void { + if (!isForumPopoverEl(el)) return + el.forumPopoverConfig.popoverObj.dispose() + el.removeEventListener('inserted.bs.popover', onTipInserted) + el.removeEventListener('hidden.bs.popover', onPopoverHidden) + el.removeEventListener('focusin', onFocusinElement) + el.removeEventListener('focusout', onFocusoutElement) + el.removeEventListener('pointerenter', onPointerEnterElement) + el.removeEventListener('pointerleave', onPointerLeaveElement) + // @ts-expect-error Popover is being destroyed so it's okay + delete el.forumPopoverConfig }