360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { Fragment, createElement, useEffect, useImperativeHandle, useRef } from 'react'
|
|
import type { CSSProperties } from 'react'
|
|
import type { VaultEntry } from '../types'
|
|
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
|
import { NoteTitleIcon } from './NoteTitleIcon'
|
|
import { getTypeIcon } from './note-item/typeIcon'
|
|
import type {
|
|
InlineWikilinkChip,
|
|
InlineWikilinkSegment,
|
|
} from './inlineWikilinkText'
|
|
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function withNativeEvent<T extends Event>(event: T): T & { nativeEvent: T } {
|
|
const eventWithNativeEvent = event as T & { nativeEvent?: T }
|
|
if (!eventWithNativeEvent.nativeEvent) {
|
|
Object.defineProperty(event, 'nativeEvent', {
|
|
configurable: true,
|
|
value: event,
|
|
})
|
|
}
|
|
return event as T & { nativeEvent: T }
|
|
}
|
|
|
|
export function InlineWikilinkChipView({
|
|
chip,
|
|
typeEntryMap,
|
|
}: {
|
|
chip: InlineWikilinkChip
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
}) {
|
|
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
|
|
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
|
|
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
|
|
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
|
|
|
|
return (
|
|
<span
|
|
contentEditable={false}
|
|
data-chip-target={chip.target}
|
|
data-testid="inline-wikilink-chip"
|
|
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
|
|
style={{
|
|
backgroundColor,
|
|
color,
|
|
padding: '1px 8px 1px 6px',
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{chip.entry.icon ? (
|
|
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
|
|
) : (
|
|
createElement(typeIcon, {
|
|
'aria-hidden': true,
|
|
width: 11,
|
|
height: 11,
|
|
className: 'shrink-0',
|
|
})
|
|
)}
|
|
<span className="truncate">{chip.entry.title}</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function InlineSuggestionRow({
|
|
suggestion,
|
|
selected,
|
|
onHover,
|
|
onSelect,
|
|
typeEntryMap,
|
|
}: {
|
|
suggestion: InlineWikilinkSuggestion
|
|
selected: boolean
|
|
onHover: () => void
|
|
onSelect: () => void
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
}) {
|
|
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
|
|
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
|
|
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
|
|
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'mx-1 flex w-[calc(100%-0.5rem)] cursor-pointer items-center justify-between rounded-md border-0 bg-transparent px-3 py-2 text-left transition-colors',
|
|
selected ? 'bg-accent' : 'hover:bg-secondary',
|
|
)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={onSelect}
|
|
onMouseEnter={onHover}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span
|
|
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
|
style={{ backgroundColor, color }}
|
|
>
|
|
{suggestion.entry.icon ? (
|
|
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
|
|
) : (
|
|
createElement(typeIcon, {
|
|
'aria-hidden': true,
|
|
width: 11,
|
|
height: 11,
|
|
className: 'shrink-0',
|
|
})
|
|
)}
|
|
</span>
|
|
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
|
|
</span>
|
|
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
|
{suggestion.entry.isA ?? 'Note'}
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function InlineWikilinkSuggestionList({
|
|
suggestions,
|
|
selectedIndex,
|
|
onHover,
|
|
onSelect,
|
|
typeEntryMap,
|
|
variant = 'floating',
|
|
emptyLabel = 'No matching notes',
|
|
}: {
|
|
suggestions: InlineWikilinkSuggestion[]
|
|
selectedIndex: number
|
|
onHover: (index: number) => void
|
|
onSelect: (index: number) => void
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
variant?: 'floating' | 'palette'
|
|
emptyLabel?: string
|
|
}) {
|
|
if (suggestions.length === 0) {
|
|
return (
|
|
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
|
|
{emptyLabel}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={variant === 'floating'
|
|
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
|
|
: 'py-1'}
|
|
data-testid="wikilink-menu"
|
|
>
|
|
{suggestions.map((suggestion, index) => (
|
|
<InlineSuggestionRow
|
|
key={`${suggestion.entry.path}:${suggestion.target}`}
|
|
suggestion={suggestion}
|
|
selected={index === selectedIndex}
|
|
onHover={() => onHover(index)}
|
|
onSelect={() => onSelect(index)}
|
|
typeEntryMap={typeEntryMap}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function InlineWikilinkEditorField({
|
|
value,
|
|
placeholder,
|
|
disabled,
|
|
inputRef,
|
|
dataTestId,
|
|
placeholderClassName,
|
|
editorClassName,
|
|
editorStyle,
|
|
onCompositionEnd,
|
|
onCompositionStart,
|
|
onInput,
|
|
onKeyDown,
|
|
onCut,
|
|
onDrop,
|
|
onPaste,
|
|
onSelectionChange,
|
|
segments,
|
|
typeEntryMap,
|
|
}: {
|
|
value: string
|
|
placeholder?: string
|
|
disabled: boolean
|
|
inputRef: React.Ref<HTMLDivElement>
|
|
dataTestId: string
|
|
placeholderClassName?: string
|
|
editorClassName?: string
|
|
editorStyle?: CSSProperties
|
|
onCompositionEnd: (editor: HTMLDivElement) => void
|
|
onCompositionStart: () => void
|
|
onInput: () => void
|
|
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
|
onCut: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
|
onDrop: (event: React.DragEvent<HTMLDivElement>) => void
|
|
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
|
onSelectionChange: () => void
|
|
segments: InlineWikilinkSegment[]
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
}) {
|
|
const editorRef = useRef<HTMLDivElement | null>(null)
|
|
const needsTrailingCaretAnchor = segments[segments.length - 1]?.kind === 'chip'
|
|
useImperativeHandle(inputRef, () => editorRef.current as HTMLDivElement, [])
|
|
useInlineWikilinkPlaceholder(editorRef, placeholder)
|
|
useInlineWikilinkEditorEvents(editorRef, {
|
|
onCompositionEnd,
|
|
onCompositionStart,
|
|
onCut,
|
|
onDrop,
|
|
onInput,
|
|
onKeyDown,
|
|
onPaste,
|
|
onSelectionChange,
|
|
})
|
|
|
|
return (
|
|
<div className="relative">
|
|
{value.length === 0 && placeholder && (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute inset-0 text-muted-foreground',
|
|
placeholderClassName ?? 'flex items-center',
|
|
)}
|
|
style={placeholderClassName ? undefined : { padding: '8px 10px', fontSize: 13 }}
|
|
>
|
|
{placeholder}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={editorRef}
|
|
contentEditable={!disabled}
|
|
suppressContentEditableWarning={true}
|
|
aria-disabled={disabled ? 'true' : undefined}
|
|
data-testid={dataTestId}
|
|
className={cn(
|
|
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
|
|
disabled && 'cursor-not-allowed opacity-60',
|
|
editorClassName,
|
|
)}
|
|
style={{ ...editorStyle, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
|
>
|
|
{segments.map((segment) => renderInlineWikilinkSegment(segment, typeEntryMap))}
|
|
{needsTrailingCaretAnchor ? '\u200B' : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type InlineWikilinkEditorHandlers = Pick<
|
|
Parameters<typeof InlineWikilinkEditorField>[0],
|
|
| 'onCompositionEnd'
|
|
| 'onCompositionStart'
|
|
| 'onCut'
|
|
| 'onDrop'
|
|
| 'onInput'
|
|
| 'onKeyDown'
|
|
| 'onPaste'
|
|
| 'onSelectionChange'
|
|
>
|
|
|
|
function useInlineWikilinkPlaceholder(editorRef: React.RefObject<HTMLDivElement | null>, placeholder?: string) {
|
|
useEffect(() => {
|
|
const editor = editorRef.current
|
|
if (!editor) return
|
|
syncPlaceholderAttribute(editor, placeholder)
|
|
}, [editorRef, placeholder])
|
|
}
|
|
|
|
function syncPlaceholderAttribute(editor: HTMLDivElement, placeholder?: string) {
|
|
if (placeholder) {
|
|
editor.setAttribute('aria-placeholder', placeholder)
|
|
return
|
|
}
|
|
editor.removeAttribute('aria-placeholder')
|
|
}
|
|
|
|
function useInlineWikilinkEditorEvents(
|
|
editorRef: React.RefObject<HTMLDivElement | null>,
|
|
handlers: InlineWikilinkEditorHandlers,
|
|
) {
|
|
useEffect(() => {
|
|
const editor = editorRef.current
|
|
if (!editor) return
|
|
|
|
const listenerMap = inlineWikilinkEditorListenerMap(handlers)
|
|
for (const [eventName, listener] of listenerMap) editor.addEventListener(eventName, listener)
|
|
return () => {
|
|
for (const [eventName, listener] of listenerMap) editor.removeEventListener(eventName, listener)
|
|
}
|
|
}, [editorRef, handlers])
|
|
}
|
|
|
|
function inlineWikilinkEditorListenerMap({
|
|
onCompositionEnd,
|
|
onCompositionStart,
|
|
onCut,
|
|
onDrop,
|
|
onInput,
|
|
onKeyDown,
|
|
onPaste,
|
|
onSelectionChange,
|
|
}: InlineWikilinkEditorHandlers): Array<[keyof HTMLElementEventMap, EventListener]> {
|
|
const handleSelectionChange = () => onSelectionChange()
|
|
return [
|
|
['compositionstart', () => onCompositionStart()],
|
|
['compositionend', (event) => onCompositionEnd(event.currentTarget as HTMLDivElement)],
|
|
['input', () => onInput()],
|
|
['keydown', (event) => onKeyDown(withNativeEvent(event) as unknown as React.KeyboardEvent<HTMLDivElement>)],
|
|
['cut', (event) => onCut(withNativeEvent(event) as unknown as React.ClipboardEvent<HTMLDivElement>)],
|
|
['drop', (event) => onDrop(withNativeEvent(event) as unknown as React.DragEvent<HTMLDivElement>)],
|
|
['paste', (event) => onPaste(withNativeEvent(event) as unknown as React.ClipboardEvent<HTMLDivElement>)],
|
|
['click', handleSelectionChange],
|
|
['keyup', handleSelectionChange],
|
|
['mouseup', handleSelectionChange],
|
|
]
|
|
}
|
|
|
|
function renderInlineWikilinkSegment(segment: InlineWikilinkSegment, typeEntryMap: Record<string, VaultEntry>) {
|
|
if (segment.kind === 'text') return <Fragment key={`text-${segment.text}`}>{segment.text}</Fragment>
|
|
return (
|
|
<InlineWikilinkChipView
|
|
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
|
|
chip={segment.chip}
|
|
typeEntryMap={typeEntryMap}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function InlineWikilinkPaletteLayout({
|
|
header,
|
|
editor,
|
|
suggestionList,
|
|
emptyState,
|
|
footer,
|
|
}: {
|
|
header?: React.ReactNode
|
|
editor: React.ReactNode
|
|
suggestionList: React.ReactNode
|
|
emptyState?: React.ReactNode
|
|
footer?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className="border-b border-border px-4 py-3">
|
|
{header}
|
|
{editor}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto py-1">
|
|
{suggestionList ?? emptyState}
|
|
</div>
|
|
{footer}
|
|
</>
|
|
)
|
|
}
|