hololake-platform/src/components/tolariaSideMenuAlignment.ts

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])
}