188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import { useLayoutEffect } from 'react'
|
|
import {
|
|
blockElementById,
|
|
editorBlockElement,
|
|
type TolariaBlockNoteEditor,
|
|
} from './tolariaBlockNoteDom'
|
|
import type { SideMenuBlock } from './tolariaSideMenuBlocks'
|
|
|
|
type SideMenuAlignmentState = {
|
|
attemptsRemaining: number
|
|
frame: number | null
|
|
hasObservedTargets: boolean
|
|
}
|
|
type SideMenuAlignmentContext = {
|
|
blockId: string
|
|
editorElement: HTMLElement
|
|
observeTargets: () => void
|
|
ownerWindow: Window
|
|
retry: () => void
|
|
state: SideMenuAlignmentState
|
|
}
|
|
|
|
const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
|
|
|
|
function sideMenuElementForEditor(editorElement: HTMLElement): HTMLElement | null {
|
|
const container = editorElement.closest('.editor__blocknote-container') ?? editorElement
|
|
const sideMenu = container.querySelector('.bn-side-menu')
|
|
return sideMenu instanceof HTMLElement ? sideMenu : null
|
|
}
|
|
|
|
function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
|
|
const content = blockElement.querySelector('.bn-block-content')
|
|
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
|
|
if (!(inlineContent instanceof HTMLElement)) return null
|
|
|
|
const ownerDocument = inlineContent.ownerDocument
|
|
const range = ownerDocument.createRange()
|
|
range.selectNodeContents(inlineContent)
|
|
const firstLineRect = Array.from(range.getClientRects())
|
|
.find((rect) => rect.width > 0 && rect.height > 0)
|
|
const textRect = firstLineRect ?? range.getBoundingClientRect()
|
|
range.detach()
|
|
|
|
if (textRect.height > 0) return textRect
|
|
|
|
const fallbackRect = inlineContent.getBoundingClientRect()
|
|
return fallbackRect.height > 0 ? fallbackRect : null
|
|
}
|
|
|
|
function alignSideMenuWithBlockText(editorElement: HTMLElement, blockId: string): boolean {
|
|
const blockElement = blockElementById(editorElement, blockId)
|
|
const sideMenu = sideMenuElementForEditor(editorElement)
|
|
if (!blockElement || !sideMenu) return false
|
|
|
|
const anchorRect = blockTextAnchorRect(blockElement)
|
|
if (!anchorRect) return false
|
|
|
|
sideMenu.style.removeProperty('translate')
|
|
const sideMenuRect = sideMenu.getBoundingClientRect()
|
|
if (sideMenuRect.height <= 0) return false
|
|
|
|
const anchorCenter = anchorRect.top + anchorRect.height / 2
|
|
const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
|
|
sideMenu.style.setProperty('translate', `0 ${anchorCenter - sideMenuCenter}px`)
|
|
return true
|
|
}
|
|
|
|
function createSideMenuAlignmentState(): SideMenuAlignmentState {
|
|
return {
|
|
attemptsRemaining: SIDE_MENU_ALIGNMENT_ATTEMPTS,
|
|
frame: null,
|
|
hasObservedTargets: false,
|
|
}
|
|
}
|
|
|
|
function createSideMenuResizeObserver(onResize: () => void): ResizeObserver | null {
|
|
return typeof ResizeObserver === 'undefined'
|
|
? null
|
|
: new ResizeObserver(onResize)
|
|
}
|
|
|
|
function observeSideMenuAlignmentTargets({
|
|
blockId,
|
|
editorElement,
|
|
resizeObserver,
|
|
state,
|
|
}: {
|
|
blockId: string
|
|
editorElement: HTMLElement
|
|
resizeObserver: ResizeObserver | null
|
|
state: SideMenuAlignmentState
|
|
}) {
|
|
if (state.hasObservedTargets) return
|
|
|
|
const blockElement = blockElementById(editorElement, blockId)
|
|
const sideMenu = sideMenuElementForEditor(editorElement)
|
|
if (!resizeObserver || !blockElement || !sideMenu) return
|
|
|
|
resizeObserver.observe(blockElement)
|
|
resizeObserver.observe(sideMenu)
|
|
state.hasObservedTargets = true
|
|
}
|
|
|
|
function scheduleSideMenuTextAlignment(context: SideMenuAlignmentContext) {
|
|
const { blockId, editorElement, observeTargets, ownerWindow, retry, state } = context
|
|
if (state.frame !== null) return
|
|
|
|
state.frame = ownerWindow.requestAnimationFrame(() => {
|
|
state.frame = null
|
|
const aligned = alignSideMenuWithBlockText(editorElement, blockId)
|
|
observeTargets()
|
|
if (!aligned && state.attemptsRemaining > 0) {
|
|
state.attemptsRemaining -= 1
|
|
retry()
|
|
}
|
|
})
|
|
}
|
|
|
|
function createSideMenuAlignmentCleanup({
|
|
editorElement,
|
|
ownerWindow,
|
|
resizeObserver,
|
|
scheduleAlignment,
|
|
state,
|
|
}: {
|
|
editorElement: HTMLElement
|
|
ownerWindow: Window
|
|
resizeObserver: ResizeObserver | null
|
|
scheduleAlignment: () => void
|
|
state: SideMenuAlignmentState
|
|
}) {
|
|
return () => {
|
|
if (state.frame !== null) ownerWindow.cancelAnimationFrame(state.frame)
|
|
resizeObserver?.disconnect()
|
|
ownerWindow.removeEventListener('resize', scheduleAlignment)
|
|
sideMenuElementForEditor(editorElement)?.style.removeProperty('translate')
|
|
}
|
|
}
|
|
|
|
function createSideMenuAlignmentController(editor: TolariaBlockNoteEditor, blockId: string) {
|
|
const editorElement = editorBlockElement(editor)
|
|
const ownerWindow = editorElement?.ownerDocument.defaultView
|
|
if (!editorElement || !ownerWindow) return undefined
|
|
|
|
const state = createSideMenuAlignmentState()
|
|
let resizeObserver: ResizeObserver | null = null
|
|
const observeTargets = () => observeSideMenuAlignmentTargets({
|
|
blockId,
|
|
editorElement,
|
|
resizeObserver,
|
|
state,
|
|
})
|
|
const scheduleAlignment = () => scheduleSideMenuTextAlignment({
|
|
blockId,
|
|
editorElement,
|
|
observeTargets,
|
|
ownerWindow,
|
|
retry: scheduleAlignment,
|
|
state,
|
|
})
|
|
|
|
resizeObserver = createSideMenuResizeObserver(scheduleAlignment)
|
|
scheduleAlignment()
|
|
observeTargets()
|
|
ownerWindow.addEventListener('resize', scheduleAlignment)
|
|
|
|
return createSideMenuAlignmentCleanup({
|
|
editorElement,
|
|
ownerWindow,
|
|
resizeObserver,
|
|
scheduleAlignment,
|
|
state,
|
|
})
|
|
}
|
|
|
|
export function useSideMenuTextAlignment(
|
|
editor: TolariaBlockNoteEditor,
|
|
block: SideMenuBlock | undefined,
|
|
) {
|
|
const blockId = block?.id
|
|
|
|
useLayoutEffect(() => {
|
|
if (!blockId) return
|
|
|
|
return createSideMenuAlignmentController(editor, blockId)
|
|
}, [blockId, editor])
|
|
}
|