import type { Editor } from '@tiptap/core' import type { Slice } from '@tiptap/pm/model' import type { EditorView } from '@tiptap/pm/view' import type { EntityID } from '@wovin/core/applog' import type { Accessor, Component, JSX, Setter } from 'solid-js' import type { BlockVM } from '../data/VMs/BlockVM' import type { RelationVM } from '../data/VMs/RelationVM' import type { FakeBlock } from './BlockTree' import { useSearchParams } from '@solidjs/router' import Image from '@tiptap/extension-image' import { untracked } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import classNames from 'classnames' import { format } from 'date-fns/format' import { parseISO } from 'date-fns/parseISO' import { pick } from 'lodash-es' import { stringify } from 'safe-stable-stringify' import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show, splitProps } from 'solid-js' import { createTiptapEditor, useEditorJSON } from 'solid-tiptap' import { useAgent } from '../data/agent/AgentState' import { useBlockContext } from '../data/block-ui-helpers' import { createTreeItemKeydownHandler } from '../data/block-ui-helpers-keydown' import { copyToClipboard, createTreeItemPasteHandler, getPlainTextFromClipboard, getValidURLFromClipboard } from '../data/block-ui-helpers-paste' import { compareBlockContent } from '../data/block-utils-nowin' import { REL_DEF } from '../data/data-types' import { useBlockKeyhandlers } from '../data/keybindings' import { plaintextStringToTiptap, tiptapToPlaintext } from '../data/note3-utils-nodeps' import { doesContentMatchSearch, useSearchContext } from '../data/search' import { useBlk } from '../data/VMs/BlockVM' import { KnownFullAttrs } from '../data/VMs/utils-typemap' import { MirrorBulletIcon } from '../icons/MirrorBulletIcon' import { activePlugins } from '../plugins/init' import { useBlockVM, useCurrentThread, useReadOnlyState, useRelationVM } from '../ui/reactive' import { devMode, EventDebugger, focusViewOnBlock, onClickOrLongPress, useLocationNavigate } from '../ui/utils-ui' import { BlockSettingsRow } from './BlockSettings' import { BulletMenu } from './BulletMenu' import { DynamicColored, Iconify, IconifyNames, useIdHover } from './mini-components' import { baseExtensions, CreateTokenHidingExtension, SelectionDetectLinkHandler as CustomSelectionHandler, htmlToTiptap, TagHighlightExtension, WordInfoExtension, } from './TipTapExtentions' import { TiptapMenuWrapper } from './TiptapMenuWrapper' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars // scale: relVM?.isMirror ? 40 : blkVM.isReply ? 70 : (blkVM.isTodo ? 100 : 150) } export const blockIconNameHooks = [ function mirrorBlockIconName(blockOrFake: BlockVM | FakeBlock, relVM?: RelationVM, likelyKids = false) { return relVM?.isMirror ? (likelyKids ? { icon: , name: IconifyNames['diamond-duotone'], scale: 70 } : { name: IconifyNames['diamond-fill'], scale: 35 }) : {} }, function replyBlockIconName(blockOrFake: BlockVM | FakeBlock, _relVM?: RelationVM, likelyKids = false) { return blockOrFake?.isReply // HACK: BlockVM doesn't know about view context (but for fake block it's good to be able to set it) ? (likelyKids ? { name: IconifyNames['arrow-bend-double-up-left-bold'], scale: 70 } : { name: IconifyNames['arrow-bend-down-right-bold'], scale: 70 }) : {} }, function todoBlockIconName(blockOrFake: BlockVM | FakeBlock, _relVM?: RelationVM, _likelyKids = false) { return blockOrFake?.isTodo ? ( blockOrFake?.isTodoDone ? { name: 'check-square-thin', scale: 100 } : { name: 'square-thin', scale: 100 } ) : {} }, ] const UnknownAttributesPills: Component<{ blockOrFake: BlockVM | FakeBlock hasContent?: boolean }> = (props) => { const unknownAttrsWithValues = createMemo(() => { if (!props.blockOrFake || !('entityThread' in props.blockOrFake)) return [] const blkVM = props.blockOrFake as BlockVM const applogs = blkVM.entityThread.applogs // Get all unique attribute names that are not in KnownFullAttrs const uniqueAttrs = new Set( applogs .filter(log => !KnownFullAttrs.Block.includes(log.at)) .map(log => log.at), ) // Get latest value for each unknown attribute return Array.from(uniqueAttrs).map((at) => { const latestLog = applogs.findLast(log => log.at === at) const value = latestLog?.vl // Truncate long values for display const displayValue = typeof value === 'string' && value.length > 50 ? `${value.substring(0, 47)}...` : value const fullValue = typeof value === 'string' ? value : JSON.stringify(value) return { name: at, displayValue, fullValue } }) }) const opacity = createMemo(() => props.hasContent ? 'opacity-50' : 'opacity-70') return ( 0}>
{attr => ( {attr.name}: {attr.displayValue} )}
) } export const BlockItem: Component<{ blockID: EntityID | null fake?: FakeBlock relationID: EntityID | null hasKids: boolean readOnly?: boolean // ? kind of redundant with thread context readonly isExpanded: Accessor setExpanded: Setter | ((newVal: boolean) => any) }> = (props) => { const { blockID, fake } = props // assumed static const debugName = untracked(() => blockID ?? JSON.stringify(fake)) if (!blockID && !fake) { throw ERROR(` with falsy blockID`, blockID) } VERBOSE(`[BlockItem#${debugName}] created`) const { locnav, location, navigate } = useLocationNavigate() const [searchParams, _setSearchParams] = useSearchParams() const previewFromUrl = createMemo(() => { DEBUG(`searchParams:`, { ...searchParams }) return searchParams.preview }) const { isExpanded, setExpanded } = props // accessor/setter, so keeps reactivity const thread = useCurrentThread() const blockContext = useBlockContext() const parentContext = blockContext?.parentContext // const relation = props.relationID && useRelation(props.relationID) const relVM = props.relationID && useRelationVM(props.relationID) const blkVM = fake ? undefined : useBlk(blockID) const parentVM = relVM ? useBlk(relVM.childOf) : null const blockOrFake = fake ?? blkVM const ag = useAgent().ag const isDeleted = createMemo(() => (blockOrFake as BlockVM).isDeleted) const readOnly = createMemo(() => props.readOnly || !!props.fake || thread.readOnly || isDeleted()) const [search] = useSearchContext() const shouldHide = createMemo(() => !fake && search() && !doesContentMatchSearch(blkVM.contentPlaintext ?? '', search())) // HACK const [blockSettings, setBlockSettings] = createSignal(false) function toggleBlockSettings() { setBlockSettings(!blockSettings()) } let blockRef: HTMLDivElement let editorRef: HTMLDivElement useBlockKeyhandlers(() => ({ blockRef, editorRef })) // TODO needs reactivity support: const pasteHandler = fake ? EventDebugger : createTreeItemPasteHandler(relVM, blkVM) // ? those too? const keyDownHandler = createTreeItemKeydownHandler(blockID, props.relationID, isExpanded, setExpanded, ag) const bulletIconProps = createMemo(() => { const likelyKids = props.hasKids || blockOrFake?.kidRelations?.length // HACK full object with name and scale (or even a jsx icon) could be returned from the hooks const iconProps = { name: likelyKids ? 'dot-duotone' : 'dot-bold', scale: 150, icon: undefined, } for (const eachFx of blockIconNameHooks) { const { name, scale, icon } = eachFx(blockOrFake, relVM, likelyKids) iconProps.name = name ?? iconProps.name iconProps.scale = scale ?? iconProps.scale iconProps.icon = icon ?? iconProps.icon if (icon) DEBUG('svgCustomIcon', { icon }) } return iconProps }) const [clickCount, setClickCount] = createSignal(0) let clickTimeout: ReturnType | null = null const DOUBLE_CLICK_DELAY = 250 const AUTOEXPAND_DEPTH = 3 const onExpandClick = (event: MouseEvent, long: boolean) => { event.preventDefault() VERBOSE.force({ event, onClickOrLongPress, clickCount: clickCount() }) if (event.button === 1 || long) { focusViewOnBlock({ id: blockID }, locnav) return } // Clear existing timeout if (clickTimeout) clearTimeout(clickTimeout) // Increment click count const newClickCount = clickCount() + 1 setClickCount(newClickCount) clickTimeout = setTimeout(() => { if (newClickCount > 1) { // Double-click: recursively expand/collapse all children const targetState = !isExpanded() setExpanded(targetState) // Notify children to recursively expand/collapse // This is done via a block context signal if (blockContext?.autoExpandDepth()) { blockContext?.setAutoExpandDepth(0) } else { blockContext?.setAutoExpandDepth(AUTOEXPAND_DEPTH) } } else { VERBOSE.force('// Single-click: toggle just this block') setExpanded(!isExpanded()) } // Reset click count setClickCount(0) }, DOUBLE_CLICK_DELAY) } // Listen for recursive expand events from parent createEffect(() => { if (!blockRef) return const handleRecursiveExpand = (event: Event) => { VERBOSE.force('// Custom recursiveExpandChange ', { event }) const customEvent = event as CustomEvent<{ shouldExpand: boolean }> setExpanded(customEvent.detail.shouldExpand) } blockRef.addEventListener('recursiveExpandChange', handleRecursiveExpand) onCleanup(() => blockRef?.removeEventListener('recursiveExpandChange', handleRecursiveExpand)) }) // wip prefix rendering placeholder const isPrefixNeeded = createMemo(() => false && blockContext?.prefix || parentContext?.prefix) const prefix = createMemo(() => { if (!isPrefixNeeded()) return '' if (parentVM) DEBUG('prefix', { parentVM, blkVM }) const latestLog = blkVM.entityThread.latestLog const firstLog = blkVM.entityThread.firstLog if (!latestLog || !firstLog) { DEBUG('prefix: missing logs', { latestLog, firstLog }) return '' } const latestTs = parseISO(latestLog.ts) const firstTs = parseISO(firstLog.ts) const creationPredicate = 'date:c(ddMMM { const content = blockOrFake?.content return content !== null && content !== undefined && (typeof content === 'string' || Object.keys(content || {}).length > 0) }) // Check if block has content to display const isHeading = createMemo(() => { const content = blockOrFake?.content VERBOSE({ content }) return content !== null && content !== undefined && (content.content?.[0]?.type === 'heading') }) // Check if there are unknown attributes to render const hasUnknownAttributes = createMemo(() => { if (fake) return false const blkVM = blockOrFake as BlockVM return blkVM.entityThread.applogs.some(log => !KnownFullAttrs.Block.includes(log.at)) }) // Determine if we should show the content editor // Hide editor if: no contentApplog AND has unknown attributes (regardless of readOnly) // Otherwise show if: has content OR not readOnly const shouldShowContent = createMemo(() => { if (!fake && !(blockOrFake as BlockVM).contentApplog && hasUnknownAttributes()) return false if (hasContent()) return true if (!readOnly()) return true return false }) return (
{/* */} {/* */}
{/*
*/} {prefix()} setBlockSettings(!blockSettings())} >
{/* {parentContext?.smartList?.query.source} */} ... AND
deleted'), })} relationID={props.relationID} data-hidden-tags={stringify(blockContext.hiddenTags?.() ?? null)} onPaste={pasteHandler} onKeyDown={keyDownHandler} readOnly={readOnly()} flex='grow-1 shrink-1' class={classNames( readOnly && 'cursor-pointer', isDeleted() && 'text-red opacity-80', )} onClick={readOnly() ? e => setExpanded(!isExpanded()) : undefined} />
{ /* */ }
B  copyToClipboard(blockID)} />
R  {' '} copyToClipboard(props.relationID)} />
↑  {' '} copyToClipboard(relVM.after))} />
{/*
*/}
{/* HACK: prop drilling */}
) } // HACK: I'm not sure how to get from dom node to Editor in another way export const editorMap = new Map() // const CustomShortcuts = Extension.create({ // name: 'CustomShortcuts', // addKeyboardShortcuts() { // return { // // HACK: move default Enter behaviour to Shift-Enter (as our Enter creates a new block) // 'Shift-Enter': ({ editor }) => // // from: https://github.com/ueberdosis/tiptap/blob/39cf6979c49e953118bbbe4b894a1dc296128932/packages/core/src/extensions/keymap.ts#L49C25-L54C7 // editor.commands.first(({ commands }) => [ // () => commands.newlineInCode(), // () => commands.createParagraphNear(), // () => commands.liftEmptyBlock(), // () => commands.splitBlock(), // ]), // } // }, // }) export const EditableContent: Component< { readOnly?: boolean blockID: EntityID | null fake?: FakeBlock relationID: EntityID | null // blockVM: BlockVM // block: typeof BLOCK // vintageContent: Accessor onKeyDown: (evt: React.KeyboardEvent) => Promise onPaste: (view: EditorView, event: ClipboardEvent, slice: Slice) => boolean | void } & JSX.HTMLAttributes > = (fullProps) => { const [props, otherProps] = splitProps(fullProps, ['readOnly', 'blockID', 'relationID', 'fake', 'onKeyDown', 'onPaste']) // (i) this function is still not properly reactive to those, though const readOnly = createMemo(() => props.readOnly || useReadOnlyState()()) VERBOSE(` created`, { props, otherProps, ro: readOnly(), ds: useCurrentThread() }) const blockVM = props.fake ?? useBlockVM(props.blockID) const [isEditing, setEditing] = createSignal(null) const [selectionLink, setSelectionLink] = createSignal<{ href: string } | null>(null) let ref!: HTMLDivElement // let menuRef!: HTMLDivElement Image.configure({ allowBase64: true, }) const initialContent = untracked(() => blockVM.content) VERBOSE(`[Content#${props.blockID}] initial`, initialContent) const fakeContent = props.fake?.content && (typeof props.fake.content === 'string' ? plaintextStringToTiptap(props.fake.content) : props.fake.content) // TODO decorate or somehow hide the outer (and maybe recursive) smartlist tokens // ... somehow based on parent block context isTokenHidden prop of smartlist const blockContext = useBlockContext() const parentContext = blockContext?.parentContext const extensionsMemo = createMemo(() => { const extensions = [ ...baseExtensions, Image, // CustomShortcuts, TagHighlightExtension, WordInfoExtension, CustomSelectionHandler(selectionLink, setSelectionLink), // BubbleMenu.configure({ // element: menuRef!, // }), ...activePlugins.flatMap(p => p.tiptapExtensions), ] const hiddenTags = parentContext?.hiddenTags() if (hiddenTags) { VERBOSE('CreateTokenHidingExtension', { hiddenTags }) extensions.push(CreateTokenHidingExtension(hiddenTags)) } return extensions }) const editor = createTiptapEditor(() => { const editorProps = { element: ref!, editable: !readOnly(), editorProps: { attributes: { // class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none', class: 'focus:outline-none -my-3', }, // handlePaste: CustomStackedPasteHandler([props.onPaste /* () => LOG('handler1'), () => LOG('handler2') */]), handlePaste: props.onPaste, }, extensions: extensionsMemo(), content: fakeContent ?? initialContent, } DEBUG(` props`, editorProps) return editorProps }) onMount(() => { editorMap.set(ref!, editor()) }) onCleanup(() => { editorMap.delete(ref!) }) const contentJson = useEditorJSON(editor) if (!readOnly()) { createEffect(on(() => contentJson(), (newContent) => { DEBUG(`[EditableContent#${props.blockID}].htmlUpdate]`, newContent, { blockVM }) if (!compareBlockContent(newContent, blockVM.content)) { DEBUG('[EditableContent#${props.blockID}].htmlUpdate] changed', { old: blockVM.content, new: newContent /* , parsed */ }) blockVM.content = newContent // this is a json object setEditing(setTimeout(() => setEditing(null), 1000)) // HACK: unify with BlockVM.isEditing // persistBlockContent(block.en, spanText) } }, { defer: true })) // (i) still called on first load (I think) } createEffect(on(() => blockVM.content, (newContent) => { DEBUG(`[EditableContent#${props.blockID}] newContent:`, newContent, { ref, isEditing: blockVM.isEditing }) if (!isEditing()) { if (!compareBlockContent(contentJson(), newContent)) { editor().commands.setContent(newContent) } } }, { defer: true })) // don't call on first load const handleLinkButton = async (_evt: PointerEvent) => { if (editor().isActive('link')) { DEBUG('removing', editor().getAttributes('link').href) // (i) tries to extend to editor().chain().focus().extendMarkRange('link').unsetLink().run() setSelectionLink(null) } else { const maybeValidURL = await getValidURLFromClipboard() if (maybeValidURL) { VERBOSE('setting link in tiptap', maybeValidURL) editor().chain().focus().toggleLink({ href: maybeValidURL }).run() setSelectionLink({ href: maybeValidURL }) // ? i guess focus is needed cuz the mini menu steals it on click ? } else { WARN('Link Button clicked but no link found', await getPlainTextFromClipboard()) } } } if (props.fake) VERBOSE('fake:', props.fake) return ( <>
{/* shouldShow={({ editor, state, view, from, to }) => { }} */} {/* FIXME: tooltips not shown */} editor().chain().focus().toggleBold().run()}> editor().chain().focus().toggleItalic().run()}> editor().chain().focus().toggleUnderline().run()}> editor().chain().focus().toggleStrike().run()}> editor().chain().focus().unsetAllMarks().run()}>
{/* divider */}
{/* */} {/* onClick={() => window.open(selectionLink().href, '_blank')} */} {/* */}
) // return ( // // {/* w-full w-min-content */} // {untracked(() => blockVM.content)} // // ) }