fix: stabilize command palette ai mode editing
This commit is contained in:
@@ -314,6 +314,7 @@ function OpenCommandPalette({
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
inputRef={aiInputRef}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
@@ -19,6 +20,7 @@ export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
inputRef,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
@@ -26,6 +28,7 @@ export function CommandPaletteAiMode({
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
inputRef={inputRef}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import {
|
||||
deleteInlineSelection,
|
||||
replaceInlineSelection,
|
||||
} from './inlineWikilinkEdits'
|
||||
import {
|
||||
buildInlineWikilinkSegments,
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
findInlineChipDeletionRange,
|
||||
} from './inlineWikilinkText'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
@@ -39,27 +42,11 @@ interface InlineWikilinkInputProps {
|
||||
paletteFooter?: ReactNode
|
||||
}
|
||||
|
||||
function deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
}: {
|
||||
direction: 'backward' | 'forward'
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
selectionIndex: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
}) {
|
||||
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
|
||||
if (!deletionRange) return false
|
||||
|
||||
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
|
||||
onSelectionIndexChange(deletionRange.start)
|
||||
return true
|
||||
function collapseSelectionRange(nextSelectionIndex: number) {
|
||||
return {
|
||||
start: nextSelectionIndex,
|
||||
end: nextSelectionIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
@@ -79,6 +66,80 @@ function submitInlineValue({
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
function renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={inputRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelectionChange={onSelectionChange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderInlineSuggestionList({
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
typeEntryMap,
|
||||
suggestionListVariant,
|
||||
suggestionEmptyLabel,
|
||||
}: {
|
||||
suggestions: ReturnType<typeof useInlineWikilinkSuggestionsState>['suggestions']
|
||||
selectedSuggestionIndex: number
|
||||
setSuggestionIndex: (index: number) => void
|
||||
selectSuggestion: (index: number) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
suggestionListVariant: 'floating' | 'palette'
|
||||
suggestionEmptyLabel: string
|
||||
}) {
|
||||
if (suggestions.length === 0) return null
|
||||
|
||||
return (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkInput({
|
||||
entries,
|
||||
value,
|
||||
@@ -102,20 +163,23 @@ export function InlineWikilinkInput({
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
selectionRange,
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setSelectionRange,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
syncSelectionRange,
|
||||
commitValueFromEditor,
|
||||
focusSelectionAt,
|
||||
focusSelectionRange,
|
||||
} = useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const activeQuery = useMemo(
|
||||
() => findActiveWikilinkQuery(value, selectionIndex),
|
||||
[selectionIndex, value],
|
||||
() => selectionRange.start === selectionRange.end
|
||||
? findActiveWikilinkQuery(value, selectionIndex)
|
||||
: null,
|
||||
[selectionIndex, selectionRange.end, selectionRange.start, value],
|
||||
)
|
||||
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
|
||||
const {
|
||||
@@ -131,18 +195,19 @@ export function InlineWikilinkInput({
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
focusSelectionAt,
|
||||
onSelectionIndexChange: (nextSelectionIndex) => setSelectionRange(collapseSelectionRange(nextSelectionIndex)),
|
||||
focusSelectionAt: (nextSelectionIndex) => focusSelectionRange(collapseSelectionRange(nextSelectionIndex)),
|
||||
})
|
||||
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
|
||||
return deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
})
|
||||
const insertText = (text: string) => {
|
||||
const nextState = replaceInlineSelection(value, selectionRange, text)
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const deleteContent = (direction: 'backward' | 'forward') => {
|
||||
const nextState = deleteInlineSelection(value, selectionRange, segments, direction)
|
||||
if (!nextState) return
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
@@ -153,36 +218,33 @@ export function InlineWikilinkInput({
|
||||
suggestionsOpen: suggestions.length > 0,
|
||||
onCycleSuggestions: cycleSuggestions,
|
||||
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
|
||||
onDeleteAdjacentChip: deleteAdjacentChip,
|
||||
onDeleteContent: deleteContent,
|
||||
onInsertText: insertText,
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={commitValueFromEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelectionChange={syncSelectionIndex}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = suggestions.length > 0 ? (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
) : null
|
||||
const editor = renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef: setCombinedRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput: commitValueFromEditor,
|
||||
onKeyDown: handleKeyDown,
|
||||
onSelectionChange: syncSelectionRange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
})
|
||||
const suggestionList = renderInlineSuggestionList({
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
typeEntryMap,
|
||||
suggestionListVariant,
|
||||
suggestionEmptyLabel,
|
||||
})
|
||||
if (suggestionListVariant === 'palette') {
|
||||
return (
|
||||
<InlineWikilinkPaletteLayout
|
||||
|
||||
@@ -3,6 +3,16 @@ import {
|
||||
normalizeInlineWikilinkValue,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineSelectionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface SelectionBoundary {
|
||||
container: Node
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function serializeInlineNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
||||
@@ -23,112 +33,147 @@ function selectionFallsOutsideEditor(
|
||||
selection: Selection | null,
|
||||
root: HTMLDivElement,
|
||||
): boolean {
|
||||
return (
|
||||
!selection ||
|
||||
selection.rangeCount === 0 ||
|
||||
!selection.anchorNode ||
|
||||
!root.contains(selection.anchorNode)
|
||||
)
|
||||
if (!selection || selection.rangeCount === 0) return true
|
||||
const range = selection.getRangeAt(0)
|
||||
return !root.contains(range.startContainer) || !root.contains(range.endContainer)
|
||||
}
|
||||
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
const currentSelection = window.getSelection()
|
||||
if (
|
||||
!currentSelection ||
|
||||
selectionFallsOutsideEditor(currentSelection, root)
|
||||
) {
|
||||
return serializeInlineNode(root).length
|
||||
}
|
||||
|
||||
const range = currentSelection.getRangeAt(0).cloneRange()
|
||||
function serializedSelectionBoundary(
|
||||
root: HTMLDivElement,
|
||||
container: Node,
|
||||
offset: number,
|
||||
): number {
|
||||
const range = document.createRange()
|
||||
range.setStart(root, 0)
|
||||
range.setEnd(container, offset)
|
||||
return serializeInlineNode(range.cloneContents()).length
|
||||
}
|
||||
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
const clampedIndex = Math.max(0, selectionIndex)
|
||||
const found = placeRangeAtIndex(root, clampedIndex, range)
|
||||
|
||||
if (!found) {
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
export function readSelectionRange(root: HTMLDivElement): InlineSelectionRange {
|
||||
const currentSelection = window.getSelection()
|
||||
if (!currentSelection || selectionFallsOutsideEditor(currentSelection, root)) {
|
||||
const index = serializeInlineNode(root).length
|
||||
return { start: index, end: index }
|
||||
}
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
const range = currentSelection.getRangeAt(0)
|
||||
return {
|
||||
start: serializedSelectionBoundary(root, range.startContainer, range.startOffset),
|
||||
end: serializedSelectionBoundary(root, range.endContainer, range.endOffset),
|
||||
}
|
||||
}
|
||||
|
||||
function placeRangeAtIndex(
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
return readSelectionRange(root).end
|
||||
}
|
||||
|
||||
function boundaryAtEditorEnd(root: HTMLDivElement): SelectionBoundary {
|
||||
return { container: root, offset: root.childNodes.length }
|
||||
}
|
||||
|
||||
function boundaryForTextNode(
|
||||
child: Node,
|
||||
remaining: number,
|
||||
): { boundary: SelectionBoundary | null; remaining: number } {
|
||||
const textLength = normalizeInlineWikilinkValue(child.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
return {
|
||||
boundary: { container: child, offset: remaining },
|
||||
remaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
boundary: null,
|
||||
remaining: remaining - textLength,
|
||||
}
|
||||
}
|
||||
|
||||
function boundaryForChip(
|
||||
node: Node,
|
||||
child: HTMLElement,
|
||||
index: number,
|
||||
remaining: number,
|
||||
): { boundary: SelectionBoundary | null; remaining: number } {
|
||||
const tokenLength = chipToken(child.dataset.chipTarget).length
|
||||
if (remaining <= 0) {
|
||||
return {
|
||||
boundary: { container: node, offset: index },
|
||||
remaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
return {
|
||||
boundary: { container: node, offset: index + 1 },
|
||||
remaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
boundary: null,
|
||||
remaining: remaining - tokenLength,
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectionBoundary(
|
||||
node: Node,
|
||||
selectionIndex: number,
|
||||
range: Range,
|
||||
): boolean {
|
||||
let remaining = selectionIndex
|
||||
): SelectionBoundary | null {
|
||||
let remaining = Math.max(0, selectionIndex)
|
||||
const children = Array.from(node.childNodes)
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
for (const [index, child] of children.entries()) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textPlacement = placeRangeInTextNode(child, remaining, range)
|
||||
if (textPlacement.placed) return true
|
||||
remaining = textPlacement.remaining
|
||||
const textBoundary = boundaryForTextNode(child, remaining)
|
||||
if (textBoundary.boundary) return textBoundary.boundary
|
||||
remaining = textBoundary.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (!(child instanceof HTMLElement)) continue
|
||||
|
||||
if (child.dataset.chipTarget) {
|
||||
const chipPlacement = placeRangeAroundChip(child, remaining, range)
|
||||
if (chipPlacement.placed) return true
|
||||
remaining = chipPlacement.remaining
|
||||
const chipBoundary = boundaryForChip(node, child, index, remaining)
|
||||
if (chipBoundary.boundary) return chipBoundary.boundary
|
||||
remaining = chipBoundary.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (placeRangeAtIndex(child, remaining, range)) return true
|
||||
remaining -= serializeInlineNode(child).length
|
||||
const childLength = serializeInlineNode(child).length
|
||||
if (remaining <= childLength) {
|
||||
return findSelectionBoundary(child, remaining)
|
||||
}
|
||||
remaining -= childLength
|
||||
}
|
||||
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
function placeRangeInTextNode(
|
||||
node: Node,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
export function applySelectionRange(
|
||||
root: HTMLDivElement,
|
||||
selectionRange: InlineSelectionRange,
|
||||
) {
|
||||
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
return { placed: false, remaining: remaining - textLength }
|
||||
const range = document.createRange()
|
||||
const serializedLength = serializeInlineNode(root).length
|
||||
const start = Math.max(0, Math.min(selectionRange.start, selectionRange.end, serializedLength))
|
||||
const end = Math.max(start, Math.min(Math.max(selectionRange.start, selectionRange.end), serializedLength))
|
||||
const startBoundary = findSelectionBoundary(root, start) ?? boundaryAtEditorEnd(root)
|
||||
const endBoundary = findSelectionBoundary(root, end) ?? boundaryAtEditorEnd(root)
|
||||
|
||||
range.setStart(startBoundary.container, startBoundary.offset)
|
||||
range.setEnd(endBoundary.container, endBoundary.offset)
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function placeRangeAroundChip(
|
||||
node: HTMLElement,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
|
||||
|
||||
if (remaining <= 0) {
|
||||
range.setStartBefore(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - tokenLength }
|
||||
applySelectionRange(root, { start: selectionIndex, end: selectionIndex })
|
||||
}
|
||||
|
||||
76
src/components/inlineWikilinkEdits.ts
Normal file
76
src/components/inlineWikilinkEdits.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { InlineSelectionRange } from './inlineWikilinkDom'
|
||||
import { findInlineChipDeletionRange, type InlineWikilinkSegment } from './inlineWikilinkText'
|
||||
|
||||
function normalizeSelectionRange(
|
||||
selection: InlineSelectionRange,
|
||||
valueLength: number,
|
||||
): InlineSelectionRange {
|
||||
const start = Math.max(0, Math.min(selection.start, selection.end, valueLength))
|
||||
const end = Math.max(start, Math.min(Math.max(selection.start, selection.end), valueLength))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function collapseSelection(index: number): InlineSelectionRange {
|
||||
return { start: index, end: index }
|
||||
}
|
||||
|
||||
export function replaceInlineSelection(
|
||||
value: string,
|
||||
selection: InlineSelectionRange,
|
||||
text: string,
|
||||
): { value: string; selection: InlineSelectionRange } {
|
||||
const normalizedSelection = normalizeSelectionRange(selection, value.length)
|
||||
const nextValue = (
|
||||
value.slice(0, normalizedSelection.start) +
|
||||
text +
|
||||
value.slice(normalizedSelection.end)
|
||||
)
|
||||
const nextIndex = normalizedSelection.start + text.length
|
||||
return {
|
||||
value: nextValue,
|
||||
selection: collapseSelection(nextIndex),
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteInlineSelection(
|
||||
value: string,
|
||||
selection: InlineSelectionRange,
|
||||
segments: InlineWikilinkSegment[],
|
||||
direction: 'backward' | 'forward',
|
||||
): { value: string; selection: InlineSelectionRange } | null {
|
||||
const normalizedSelection = normalizeSelectionRange(selection, value.length)
|
||||
|
||||
if (normalizedSelection.start !== normalizedSelection.end) {
|
||||
return {
|
||||
value: value.slice(0, normalizedSelection.start) + value.slice(normalizedSelection.end),
|
||||
selection: collapseSelection(normalizedSelection.start),
|
||||
}
|
||||
}
|
||||
|
||||
const deletionRange = findInlineChipDeletionRange(
|
||||
segments,
|
||||
normalizedSelection.start,
|
||||
direction,
|
||||
)
|
||||
if (deletionRange) {
|
||||
return {
|
||||
value: value.slice(0, deletionRange.start) + value.slice(deletionRange.end),
|
||||
selection: collapseSelection(deletionRange.start),
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'backward') {
|
||||
if (normalizedSelection.start === 0) return null
|
||||
const nextIndex = normalizedSelection.start - 1
|
||||
return {
|
||||
value: value.slice(0, nextIndex) + value.slice(normalizedSelection.start),
|
||||
selection: collapseSelection(nextIndex),
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSelection.start >= value.length) return null
|
||||
return {
|
||||
value: value.slice(0, normalizedSelection.start) + value.slice(normalizedSelection.start + 1),
|
||||
selection: collapseSelection(normalizedSelection.start),
|
||||
}
|
||||
}
|
||||
@@ -38,26 +38,46 @@ function handleSuggestionKeys({
|
||||
|
||||
interface HandleDeleteKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
onDeleteContent: (direction: 'backward' | 'forward') => void
|
||||
}
|
||||
|
||||
function handleDeleteKeys({
|
||||
event,
|
||||
onDeleteAdjacentChip,
|
||||
onDeleteContent,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
onDeleteContent('backward')
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
|
||||
if (event.key === 'Delete') {
|
||||
event.preventDefault()
|
||||
onDeleteContent('forward')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleInsertTextArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onInsertText: (text: string) => void
|
||||
}
|
||||
|
||||
function handleInsertText({
|
||||
event,
|
||||
onInsertText,
|
||||
}: HandleInsertTextArgs): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false
|
||||
if (event.isComposing) return false
|
||||
if (event.key.length !== 1) return false
|
||||
|
||||
event.preventDefault()
|
||||
onInsertText(event.key)
|
||||
return true
|
||||
}
|
||||
|
||||
interface HandleSubmitKeyArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
canSubmit: boolean
|
||||
@@ -83,7 +103,8 @@ interface HandleInlineWikilinkKeyDownArgs {
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
onDeleteContent: (direction: 'backward' | 'forward') => void
|
||||
onInsertText: (text: string) => void
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
@@ -94,7 +115,8 @@ export function handleInlineWikilinkKeyDown({
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
onDeleteAdjacentChip,
|
||||
onDeleteContent,
|
||||
onInsertText,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleInlineWikilinkKeyDownArgs) {
|
||||
@@ -109,7 +131,11 @@ export function handleInlineWikilinkKeyDown({
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
|
||||
if (handleDeleteKeys({ event, onDeleteContent })) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleInsertText({ event, onInsertText })) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
readSelectionIndex,
|
||||
applySelectionRange,
|
||||
readSelectionRange,
|
||||
serializeInlineNode,
|
||||
type InlineSelectionRange,
|
||||
} from './inlineWikilinkDom'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
@@ -23,7 +24,10 @@ export function useInlineWikilinkSelection({
|
||||
inputRef,
|
||||
}: UseInlineWikilinkSelectionArgs) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const [selectionIndex, setSelectionIndex] = useState(value.length)
|
||||
const [selectionRange, setSelectionRange] = useState<InlineSelectionRange>({
|
||||
start: value.length,
|
||||
end: value.length,
|
||||
})
|
||||
|
||||
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
editorRef.current = node
|
||||
@@ -32,41 +36,45 @@ export function useInlineWikilinkSelection({
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
const syncSelectionIndex = useCallback(() => {
|
||||
const syncSelectionRange = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
setSelectionIndex(readSelectionIndex(editorRef.current))
|
||||
setSelectionRange(readSelectionRange(editorRef.current))
|
||||
}, [])
|
||||
|
||||
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
|
||||
const focusSelectionRange = useCallback((nextSelectionRange: InlineSelectionRange) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
applySelectionIndex(editor, nextSelectionIndex)
|
||||
applySelectionRange(editor, nextSelectionRange)
|
||||
}, [])
|
||||
|
||||
const commitValueFromEditor = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
|
||||
const nextSelectionIndex = readSelectionIndex(editorRef.current)
|
||||
const nextSelectionRange = readSelectionRange(editorRef.current)
|
||||
|
||||
onChange(nextValue)
|
||||
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
|
||||
setSelectionRange({
|
||||
start: Math.min(nextSelectionRange.start, nextValue.length),
|
||||
end: Math.min(nextSelectionRange.end, nextValue.length),
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
if (document.activeElement !== editor) return
|
||||
applySelectionIndex(editor, selectionIndex)
|
||||
}, [selectionIndex, value])
|
||||
applySelectionRange(editor, selectionRange)
|
||||
}, [selectionRange, value])
|
||||
|
||||
return {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
selectionRange,
|
||||
selectionIndex: selectionRange.end,
|
||||
setSelectionRange,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
focusSelectionAt,
|
||||
syncSelectionRange,
|
||||
focusSelectionRange,
|
||||
commitValueFromEditor,
|
||||
}
|
||||
}
|
||||
|
||||
41
tests/smoke/command-palette-ai-mode-regression.spec.ts
Normal file
41
tests/smoke/command-palette-ai-mode-regression.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette } from './helpers'
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
test.describe('Command palette AI mode regression', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('keeps focus, supports inline chip edits, and survives selection deletion', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
await openCommandPalette(page)
|
||||
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
|
||||
|
||||
const aiInput = page.getByTestId('command-palette-ai-input')
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expect(aiInput).toBeFocused()
|
||||
|
||||
await page.keyboard.type('edit my [[b')
|
||||
await expect(page.getByTestId('wikilink-menu')).toContainText('Build Laputa App')
|
||||
|
||||
await page.getByTestId('wikilink-menu').getByText('Build Laputa App').click()
|
||||
await expect(aiInput.getByTestId('inline-wikilink-chip')).toContainText('Build Laputa App')
|
||||
|
||||
await page.keyboard.type(' essay')
|
||||
await expectNormalizedEditorText(aiInput, 'edit my Build Laputa App essay')
|
||||
|
||||
await selectEditorTextRange(page, 'command-palette-ai-input', 5)
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
41
tests/smoke/inline-wikilink-editor-regression.spec.ts
Normal file
41
tests/smoke/inline-wikilink-editor-regression.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
test.describe('Inline wikilink editor regression', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.locator('.app__note-list .cursor-pointer').first().click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
await page.getByTitle('Open AI Chat').click()
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
|
||||
})
|
||||
|
||||
test('keeps inline chip editing stable after insertion and range deletion', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
const editor = page.getByTestId('agent-input')
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
await page.keyboard.type('edit my [[b')
|
||||
await expect(page.getByTestId('wikilink-menu')).toContainText('Build Laputa App')
|
||||
|
||||
await page.getByTestId('wikilink-menu').getByText('Build Laputa App').click()
|
||||
await expect(editor.getByTestId('inline-wikilink-chip')).toContainText('Build Laputa App')
|
||||
|
||||
await page.keyboard.type(' essay')
|
||||
await expectNormalizedEditorText(editor, 'edit my Build Laputa App essay')
|
||||
|
||||
await selectEditorTextRange(page, 'agent-input', 5)
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
await expect(editor).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
57
tests/smoke/inlineWikilinkEditorHelpers.ts
Normal file
57
tests/smoke/inlineWikilinkEditorHelpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
interface SelectEditorTextRangeArgs {
|
||||
dataTestId: string
|
||||
startOffset: number
|
||||
}
|
||||
|
||||
export function trackPageErrors(page: Page): string[] {
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
return pageErrors
|
||||
}
|
||||
|
||||
export async function expectNormalizedEditorText(
|
||||
editor: Locator,
|
||||
expectedText: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => normalizeEditorText(await editor.textContent()))
|
||||
.toBe(expectedText)
|
||||
}
|
||||
|
||||
export async function selectEditorTextRange(
|
||||
page: Page,
|
||||
dataTestId: string,
|
||||
startOffset: number,
|
||||
): Promise<void> {
|
||||
await page.evaluate(selectEditorTextRangeInBrowser, { dataTestId, startOffset })
|
||||
}
|
||||
|
||||
export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => pageErrors, { timeout: 2_000 })
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
function normalizeEditorText(value: string | null): string {
|
||||
return value?.replace(/\s+/g, ' ').trim() ?? ''
|
||||
}
|
||||
|
||||
function selectEditorTextRangeInBrowser({
|
||||
dataTestId,
|
||||
startOffset,
|
||||
}: SelectEditorTextRangeArgs): void {
|
||||
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
|
||||
const selection = window.getSelection()
|
||||
const firstText = editor?.firstElementChild?.firstChild as Text | null
|
||||
const lastText = editor?.lastElementChild?.firstChild as Text | null
|
||||
const prerequisites = [selection, firstText, lastText]
|
||||
|
||||
if (prerequisites.some(value => !value)) return
|
||||
const range = document.createRange()
|
||||
range.setStart(firstText, startOffset)
|
||||
range.setEnd(lastText, lastText.textContent?.length ?? 0)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
Reference in New Issue
Block a user