import React from 'react'; import ReactDOM from 'react-dom'; import { $, EngineInterface, isEngine, isMobile, Range, UI_SELECTOR, } from '@aomao/engine'; import type { NodeInterface, EditorInterface } from '@aomao/engine'; import Toolbar from '../../Toolbar'; import type { GroupItemProps } from '../../Toolbar'; type PopupOptions = { items?: GroupItemProps[]; }; export default class Popup { #editor: EditorInterface; #root: NodeInterface; #point: Record<'left' | 'top', number> = { left: 0, top: -9999 }; #align: 'top' | 'bottom' = 'bottom'; #options: PopupOptions = {}; constructor(editor: EditorInterface, options: PopupOptions = {}) { this.#options = options; this.#editor = editor; this.#root = $(`
`); ( this.#editor.scrollNode?.get() || document.body ).appendChild(this.#root[0]); if (isEngine(editor)) { this.#editor.on('selectEnd', this.onSelect); } else { document.addEventListener('selectionchange', this.onSelect); } if (!isMobile) window.addEventListener('scroll', this.onSelect); window.addEventListener('resize', this.onSelect); this.#editor.scrollNode?.on('scroll', this.onSelect); document.addEventListener('mousedown', this.hide); } onSelect = () => { if (this.#root.length === 0) return; const range = Range.from(this.#editor) ?.cloneRange() .shrinkToTextNode(); const selection = window.getSelection(); if ( !range || !selection || !selection.focusNode || range.collapsed || this.#editor.card.getSingleSelectedCard(range) || (!range.commonAncestorNode.inEditor(this.#editor.container) && !range.commonAncestorNode.isRoot(this.#editor.container)) ) { this.hide(); return; } const next = range.startNode.next(); if ( next?.isElement() && Math.abs(range.endOffset - range.startOffset) === 1 ) { const component = this.#editor.card.closest(next); if (component) { this.hide(); return; } } const prev = range.startNode.prev(); if ( prev?.isElement() && Math.abs(range.startOffset - range.endOffset) === 1 ) { const component = this.#editor.card.closest(prev); if (component) { this.hide(); return; } } const subRanges = range.getSubRanges(true); const activeCard = this.#editor.card.active; if (subRanges.length === 0 || (activeCard && !activeCard.isEditable)) { this.hide(); return; } const topRange = subRanges[0]; const bottomRange = subRanges[subRanges.length - 1]; const topRect = topRange .cloneRange() .collapse(true) .getBoundingClientRect(); const bottomRect = bottomRange .cloneRange() .collapse(false) .getBoundingClientRect(); let rootRect: DOMRect | undefined = undefined; this.showContent(() => { rootRect = this.#root.get()?.getBoundingClientRect(); if (!rootRect) { this.hide(); return; } this.#align = bottomRange.startNode.equal(selection.focusNode!) && (!topRange.startNode.equal(selection.focusNode!) || selection.focusOffset > selection.anchorOffset) ? 'bottom' : 'top'; const space = 12; let targetRect = this.#align === 'bottom' ? bottomRect : topRect; if ( this.#align === 'top' && targetRect.top - rootRect.height - space < window.innerHeight - (this.#editor.scrollNode?.height() || 0) ) { this.#align = 'bottom'; } else if ( this.#align === 'bottom' && targetRect.bottom + rootRect.height + space > window.innerHeight ) { this.#align = 'top'; } targetRect = this.#align === 'bottom' ? bottomRect : topRect; const scrollElement = this.#editor.scrollNode?.get(); const scrollNodeRect = scrollElement?.getBoundingClientRect(); const top = this.#align === 'top' ? targetRect.top - rootRect.height - space - (scrollNodeRect?.top || 0) + (scrollElement?.scrollTop || 0) : targetRect.bottom + space - (scrollNodeRect?.top || 0) + (scrollElement?.scrollTop || 0); let left = targetRect.left - (scrollNodeRect?.left || 0) + (scrollElement?.scrollLeft || 0) + targetRect.width - rootRect.width / 2; if (left < 0) left = 16; this.#point = { left, top, }; this.#root.css({ left: `${this.#point.left}px`, top: `${this.#point.top}px`, }); }); }; showContent(callback?: () => void) { const result = this.#editor.trigger('toolbar-render', this.#options); if (!result && (this.#options.items || []).length === 0) { this.hide(); return; } ReactDOM.render( typeof result === 'object' ? ( result ) : ( ), this.#root.get()!, () => { if (callback) callback(); }, ); } hide = (event?: MouseEvent) => { if (event?.target) { const target = $(event.target); if ( target.closest('.data-toolbar-popup-wrapper').length > 0 || target.closest(UI_SELECTOR).length > 0 ) return; } this.#root.css({ left: '0px', top: '-9999px', }); }; destroy() { this.#root.remove(); if (isEngine(this.#editor)) { this.#editor.off('select', this.onSelect); } else { document.removeEventListener('selectionchange', this.onSelect); } if (!isMobile) window.removeEventListener('scroll', this.onSelect); window.removeEventListener('resize', this.onSelect); this.#editor.scrollNode?.off('scroll', this.onSelect); document.removeEventListener('mousedown', this.hide); } } export type { GroupItemProps };