import type { Location, Navigator } from '@solidjs/router' import type { ThreadOnlyCurrentNoDeleted } from '@wovin/core' import type { EntityID } from '@wovin/core/applog' import type { Accessor } from 'solid-js' import type { BlockContextType } from '../components/BlockTree' import type { LocNav } from '../ui/utils-ui' import type { AddBlockOpts } from './block-utils' import { recurseWithLoopProtection } from '@note3/utils' import { action } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import { onCleanup, onMount } from 'solid-js' import { editorMap } from '../components/BlockContent' import { syncState } from '../ipfs/sync-service' import { useCurrentThread, useFocus, useKidRelations, useParents } from '../ui/reactive' import { focusBlockAsInput, focusViewOnBlock, useLocationNavigate, useZenMode } from '../ui/utils-ui' import { useBlockContext } from './block-ui-helpers' import { addBlock, removeBlockFromRelChain, removeBlockRelAndMaybeDelete } from './block-utils' import { useSearchContext } from './search' import { tagMatcherForAutoComplete } from './tagMatcherForAutoComplete' import { useBlk } from './VMs/BlockVM' import { useRel } from './VMs/RelationVM' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.DEBUG) const RE_SLASH_COMMAND = new RegExp('/([a-z]+)', 'i') const RE_SLASH_COMMAND_WITHCONTEXT = new RegExp(`(?:^|\\s)(${RE_SLASH_COMMAND.source})`, 'i') export function useGlobalInputHandlers() { const focus = useFocus() const { locnav, location, navigate } = useLocationNavigate() const thread = useCurrentThread() const [search, setSearch] = useSearchContext() const [zenMode, setZenMode] = useZenMode() // Capture keydown before they reach app components: const handleKeyDownCapturePhase = action((event: KeyboardEvent) => { VERBOSE(`[useGlobalInputHandlers(keydown.capture)]`, event) syncState.lastNonIdleTime = new Date() // CTRL // if (event.ctrlKey) { // if (event.key === 'h') { - "history" in chromium if (event.key === '\'') { navigate('/home') return event.stopPropagation() } if (event.key === ';') { navigate('/timeline') return event.stopPropagation() } if (event.key === ',') { navigate('/settings') return event.stopPropagation() } if (event.shiftKey && event.key === 'Z') { setZenMode(!zenMode()) } } // Enter // if (event.altKey && event.key === 'Enter') { // if (!event.target?.closest?.('[data-block-id]')) { event.preventDefault() // prevent tiptap from getting this event.stopImmediatePropagation() // prevent tiptap from getting this if (search()) setSearch(null) createKidInFocusOrRoot(event.ctrlKey ? null : focus(), thread, locnav) } }) // Handle keydown if app components didn't bother: const handleKeyDownBubblePhase = action((event: KeyboardEvent) => { VERBOSE(`[useGlobalInputHandlers(keydown.bubble)]`, event) // Zoom in & out // if (event.shiftKey && event.altKey && focus()) { // ℹ if not focussed, not clear where to zoom without block focus, otherwise let block listener handle it (for keeping inputFocus) if (event.key === 'ArrowLeft') { // TODO: if (ctrlKey) top-most parent zoomOut({ focus: focus(), location, navigate }) return event.stopPropagation() } // ℹ Zoom in is block-specific // zoomIn({ focus: focus(), location, navigate }) } }) document.addEventListener('keydown', handleKeyDownCapturePhase, true) onCleanup(() => { document.removeEventListener('keydown', handleKeyDownCapturePhase, true) }) document.addEventListener('keydown', handleKeyDownBubblePhase, false) onCleanup(() => { document.removeEventListener('keydown', handleKeyDownBubblePhase, false) }) const handleMouseDown = action((event: MouseEvent) => { VERBOSE(`[useGlobalInputHandlers]`, event) syncState.lastNonIdleTime = new Date() }) document.addEventListener('mousedown', handleMouseDown, true) onCleanup(() => { document.removeEventListener('mousedown', handleMouseDown, true) }) } export function createKidInFocusOrRoot( focus: string | null, thread: ThreadOnlyCurrentNoDeleted, locnav: LocNav, ) { const otherKids = focus && useKidRelations(focus) const blockID = addBlock({ thread, asChildOf: focus, inputFocus: true, after: otherKids?.[otherKids.length - 1]?.block, }) if (!focus) { focusViewOnBlock({ id: blockID }, locnav) } } export function useBlockKeyhandlers( refs: Accessor<{ blockRef: HTMLDivElement, editorRef: HTMLDivElement }>, ) { const focus = useFocus() const blockContext = useBlockContext() const blockID = blockContext.id // (i) non-reactive, but parent component should never change ID const { locnav, location, navigate } = useLocationNavigate() const blockVM = useBlk(blockContext.id) const relToParentVM = useRel(blockContext.relationToParent) const thread = useCurrentThread() const handleKeyUp = tagMatcherForAutoComplete(editorMap, refs) const handleKeyDown = action((event: KeyboardEvent) => { const editor = editorMap.get(refs().editorRef) const text = editor.getText() const keyCode = event.keyCode if (keyCode < 48 || keyCode > 90) { // don't trace normal keys DEBUG(`[useBlockKeyhandler#${blockID}]`, event, { focus: focus(), blockContext }) } // Zoom in & out // if (event.shiftKey && event.altKey) { if (event.key === 'ArrowLeft' && !event.ctrlKey) { // (i) ctrl+ is in global handler zoomOut({ focus: focus(), blockContext, blockID, location, navigate }) return event.stopPropagation() } if (event.key === 'ArrowRight') { if (event.ctrlKey) focusViewOnBlock({ id: blockID, inputFocus: blockID }, { location, navigate }) else zoomIn({ focus: focus(), blockContext, blockID, locnav }) return event.stopPropagation() } } // if (event.altKey) { // // Create new node above // // if (event.key === 'Enter') { // createNodeAbove(thread, event, blockID, blockContext, focus, location, navigate) // return event.stopPropagation() // } // } // Enter // if (event.key === 'Enter') { if (event.shiftKey && !event.altKey && !event.ctrlKey) return // allow linebreaks event.preventDefault() // prevent tiptap from getting this event.stopImmediatePropagation() // prevent tiptap from getting this const isDisplayRoot = !blockContext.parentContext const isAtStart = false // TODO: tiptap position detection // DEBUG('enter pos', { pos, beforePos, afterPos, isDisplayRoot }) // TODO: duplicate with global handler? // if (/*isDisplayRoot && */event.altKey && event.ctrlKey) { // DEBUG('// add root node') // addBlock({ thread, asChildOf: null, /* relationsOfParent, */ focusAsInput: true,zoomTo:!!focus() }) // } else if (event.ctrlKey || isDisplayRoot) { addBlock({ thread, asChildOf: blockID, /* relationsOfParent, */ inputFocus: true }) } else if (isAtStart && relToParentVM) { // add sibling above addBlock({ thread, asChildOf: relToParentVM.childOf, after: relToParentVM.after, inputFocus: true, }) } else { ////// // split or new below const kidProps: AddBlockOpts = { thread, asChildOf: relToParentVM?.childOf, after: blockID, inputFocus: true, } // TODO: if (isExpanded) { // kidProps.asChildOf = blockID // kidProps.after = null // // ? should enter create kid at start or end? // // ? discuss if this ought to be blockID (that the first kid uses the parent as after) // } // TODO: splitting // if (pos < innerText.length - 1) { // event.currentTarget.innerText = beforePos // kidProps.content = afterPos // } // block.content = beforePos DEBUG('split or add below', { blockVM, kidProps }) addBlock(kidProps) } } // Space // if (event.code === 'Space') { if (event.shiftKey || event.altKey || event.ctrlKey) return const slashCmdMatch = RE_SLASH_COMMAND_WITHCONTEXT.exec(text) DEBUG(`[useBlockKeyhandler#${blockID}] space `, { editor, text, slashCmdMatch }) if (slashCmdMatch) { const cmd = slashCmdMatch[2].toLocaleLowerCase() if (['sl', 'smartlist'].includes(cmd)) { LOG(`Converting block to smartlist:`, blockID, { thread, bVMThread: blockVM.thread, logs: [...blockVM.entityThread.applogs] }) blockVM.cancelDebouncedContent() // HACK: remove slashCmdMatch[1] in content editor.commands.setContent(text.replace(slashCmdMatch[0], '').trim()) blockVM.buildUpdate({ content: null, type: 'smartlist', }).commit(thread) // focusBlockAsInput({ id: blockID }) - actually, the editor isn't even re-rendered return event.stopPropagation() } } } // Backspace // if (event.code === 'Backspace') { if (event.shiftKey || event.altKey || event.ctrlKey) return DEBUG(`[useBlockKeyhandler#${blockID}] Backspace `, { editor, text }) if (blockContext.relationToParent && text.length === 0) { removeBlockRelAndMaybeDelete(thread, blockID, blockContext.relationToParent) removeBlockFromRelChain(thread, blockID, blockContext.relationToParent) focusBlockAsInput({ id: relToParentVM.after ?? relToParentVM.childOf, end: true }) } } }) onMount(() => { const ref = refs().blockRef ref.addEventListener('keydown', handleKeyDown, true) ref.addEventListener('keyup', handleKeyUp, true) onCleanup(() => { ref.removeEventListener('keydown', handleKeyDown, true) ref.removeEventListener('keyup', handleKeyUp, true) }) }) } function zoomIn({ focus, blockContext, blockID, locnav }: { focus: EntityID | null blockContext: BlockContextType blockID?: EntityID locnav: LocNav }) { DEBUG(`zoom in`, { focus, blockContext, blockID }) if (blockID === focus) return let newFocus: EntityID if (focus) { newFocus = getParentUntil(blockID, focus) if (!newFocus) throw ERROR('blockID not a child of focus', { blockID, focus }) } else { // if not focussed, we want the topmost visible parent const topmostContext = recurseWithLoopProtection( blockContext, c => c.parentContext, c => c.id, ) newFocus = topmostContext.id } focusViewOnBlock( { id: newFocus, inputFocus: blockID, }, locnav, ) } function zoomOut({ blockContext, focus, blockID, location, navigate }: { focus: EntityID | null blockID?: EntityID blockContext?: BlockContextType location: Location navigate: Navigator }) { let parent = blockContext?.parentContext?.id DEBUG(`zoom out`, { parent, blockContext, focus, blockID }) if (focus /* === blockID */) { parent = getParent(focus) } if (!parent) { return // ? or go home? } // zoomToBlock({ id: blockContext.parentContext?.id, focus: focus() }, location, navigate) focusViewOnBlock({ id: parent, inputFocus: blockID }, { location, navigate }) } function getParent(blockID: EntityID) { return useParents(blockID)[0] // HACK: needs multi parent breadcrumbs url situation } function getParents(blockID: EntityID) { return useParents(blockID) } /** get the parent below `until` */ export function getParentUntil(blockID: EntityID, until: EntityID, trace: readonly EntityID[] = []) { const parents = getParents(blockID) DEBUG.force(`[getParentUntil]`, { blockID, until, parents, trace }) if (!parents.length) return null // ERROR(`[getParentUntil] did not find`, { blockID, until }) if (parents.includes(until)) return blockID for (const parent of parents) { if (trace.includes(parent)) { WARN(`[getParentUntil] recursion loop, skipping parent`, { blockID, until, parent }) continue } const matchViaParent = getParentUntil(parent, until, [...trace, blockID]) if (matchViaParent) return matchViaParent } return null } function createNodeAbove( thread: ThreadOnlyCurrentNoDeleted, event: KeyboardEvent, blockID: EntityID, blockContext: BlockContextType, focus: Accessor, location: Location, navigate: Navigator, ) { let asChildOf = null if (!event.ctrlKey && focus()) { asChildOf = blockContext.parentContext?.id } const newBlockID = addBlock({ thread, asChildOf, after: blockID }) LOG(`New block created`, newBlockID, { focus: focus() }) focusViewOnBlock({ id: newBlockID, inputFocus: newBlockID }, { location, navigate }) }