146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
const ROOT_EDITABLE_SELECTOR = '.ProseMirror[contenteditable="true"]'
|
|
const FALLBACK_EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"]'
|
|
const MAX_FOCUS_ATTEMPTS = 12
|
|
const MAX_TITLE_SELECTION_ATTEMPTS = 12
|
|
|
|
interface HeadingRange {
|
|
from: number
|
|
to: number
|
|
}
|
|
|
|
interface TiptapChain {
|
|
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
|
run: () => void
|
|
}
|
|
|
|
export interface TiptapEditor {
|
|
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
|
chain: () => TiptapChain
|
|
}
|
|
|
|
export interface FocusableEditor {
|
|
focus: () => void
|
|
_tiptapEditor?: TiptapEditor
|
|
}
|
|
|
|
function buildHeadingRange(pos: number, nodeSize: number): HeadingRange | null {
|
|
const range = { from: pos + 1, to: pos + nodeSize - 1 }
|
|
return range.from <= range.to ? range : null
|
|
}
|
|
|
|
function findFirstHeadingRange(tiptap: TiptapEditor): HeadingRange | null {
|
|
let range: HeadingRange | null = null
|
|
|
|
tiptap.state.doc.descendants((node, pos) => {
|
|
if (range) return false
|
|
if (node.type.name !== 'heading') return
|
|
|
|
range = buildHeadingRange(pos, node.nodeSize)
|
|
return false
|
|
})
|
|
|
|
return range
|
|
}
|
|
|
|
function trySelectFirstHeading(editor: FocusableEditor): boolean {
|
|
const tiptap = editor._tiptapEditor
|
|
if (!tiptap?.state?.doc) return false
|
|
|
|
const range = findFirstHeadingRange(tiptap)
|
|
if (!range) return false
|
|
|
|
tiptap.chain().setTextSelection(range).run()
|
|
return true
|
|
}
|
|
|
|
function hasEditableFocus(): boolean {
|
|
const active = document.activeElement as HTMLElement | null
|
|
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
|
}
|
|
|
|
function canFocusWindow(): boolean {
|
|
return !navigator.userAgent.toLowerCase().includes('jsdom')
|
|
}
|
|
|
|
function focusEditableCandidate(editable: HTMLElement): boolean {
|
|
if (canFocusWindow()) {
|
|
window.focus?.()
|
|
}
|
|
editable.focus()
|
|
|
|
if (hasEditableFocus()) return true
|
|
|
|
const selection = window.getSelection()
|
|
if (selection && editable.isContentEditable) {
|
|
const range = document.createRange()
|
|
range.selectNodeContents(editable)
|
|
range.collapse(true)
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
editable.focus()
|
|
}
|
|
|
|
return hasEditableFocus()
|
|
}
|
|
|
|
function focusEditableNode(): boolean {
|
|
const rootEditable = document.querySelector<HTMLElement>(ROOT_EDITABLE_SELECTOR)
|
|
if (rootEditable && focusEditableCandidate(rootEditable)) {
|
|
return true
|
|
}
|
|
|
|
const fallbackEditable = document.querySelector<HTMLElement>(FALLBACK_EDITABLE_SELECTOR)
|
|
if (fallbackEditable && focusEditableCandidate(fallbackEditable)) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function ensureEditableFocus(): boolean {
|
|
if (hasEditableFocus()) return true
|
|
focusEditableNode()
|
|
return hasEditableFocus()
|
|
}
|
|
|
|
function logFocusTiming(t0: number | undefined, label: 'focus' | 'focus+select'): void {
|
|
if (!t0) return
|
|
console.debug(`[perf] createNote → ${label}: ${(performance.now() - t0).toFixed(1)}ms`)
|
|
}
|
|
|
|
function selectTitleWithRetries(
|
|
editor: FocusableEditor,
|
|
t0: number | undefined,
|
|
attempt = 0,
|
|
): void {
|
|
const selectedHeading = ensureEditableFocus() && trySelectFirstHeading(editor)
|
|
|
|
if (selectedHeading || attempt >= MAX_TITLE_SELECTION_ATTEMPTS) {
|
|
logFocusTiming(t0, 'focus+select')
|
|
return
|
|
}
|
|
|
|
requestAnimationFrame(() => selectTitleWithRetries(editor, t0, attempt + 1))
|
|
}
|
|
|
|
export function focusEditorWithRetries(
|
|
editor: FocusableEditor,
|
|
selectTitle: boolean,
|
|
t0: number | undefined,
|
|
attempt = 0,
|
|
): void {
|
|
editor.focus()
|
|
const hasFocus = ensureEditableFocus()
|
|
if (!hasFocus && attempt < MAX_FOCUS_ATTEMPTS) {
|
|
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0, attempt + 1))
|
|
return
|
|
}
|
|
if (!selectTitle) {
|
|
logFocusTiming(t0, 'focus')
|
|
return
|
|
}
|
|
// The first heading can arrive a frame or two later than the initial focus
|
|
// on slower CI and native tab-swap paths, so keep retrying until it exists.
|
|
requestAnimationFrame(() => selectTitleWithRetries(editor, t0))
|
|
}
|