Files
deepseek-harness/packages/client/ui-conversation/src/client/input/editor/runtime.ts
T

478 lines
20 KiB
TypeScript

/** The Composer model's private Lexical editor, projections, and node operations. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor, NodeKey } from 'lexical'
import {
$addUpdateTag, $createParagraphNode, $createTextNode, $getAdjacentNode, $isTextNode,
$getRoot, $getSelection, $getNodeByKey, $isRangeSelection,
BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_BEFORE_CRITICAL, COMMAND_PRIORITY_CRITICAL, createEditor,
HISTORY_MERGE_TAG, PASTE_TAG,
RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, HISTORY_PUSH_TAG,
} from 'lexical'
import { registerPlainText } from '@lexical/plain-text'
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
import { mergeRegister } from '@lexical/utils'
import type { DraftQuote, DraftReference, ReferenceInsert } from '../../contract/draft-editor.ts'
import type { CommandClaim } from '../../contract/input.ts'
import { QuoteNode, $createQuoteNode, $isQuoteNode, REMOVE_QUOTE_COMMAND } from './QuoteNode.tsx'
import type { QuoteNodeLabels } from './QuoteNode.tsx'
import { registerReferenceActivation } from './reference-activation.ts'
import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx'
import { registerCommandChipSelection } from './command-chip-selection.ts'
import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './text-ref.ts'
import type { EditorProjection } from './projection.ts'
import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts'
import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './span-map.ts'
import type { DetectSpan } from './span-map.ts'
type Lexicon = ReadonlyMap<'/' | '@', readonly string[]>
/** Model callbacks read at the same editor registration and update points. */
interface DraftEditorRuntimeDeps {
readonly onUpdate: () => void
readonly quoteLabels: () => QuoteNodeLabels
readonly openReference: (source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>) => boolean
readonly lexicon: () => Lexicon
readonly resolveLexicon: () => ObservableSnapshot<Lexicon> | undefined
}
/**
* Detect-projection and legacy reference placeholders stripped from every
* external text entering the document (paste, persisted-draft seed): a chip
* is the only legitimate source of U+FFFC in the detect projection, so a
* literal one in text would forge chip positions.
*/
const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/**
* Append one paragraph per line of `text` to the editor root and move the caret
* to the end. Must run inside an editor update.
* @param text - sanitized plain text.
*/
function $appendParagraphs(text: string): void {
const root = $getRoot()
for (const line of text.split('\n')) {
const paragraph = $createParagraphNode()
if (line !== '') paragraph.append($createTextNode(line))
root.append(paragraph)
}
root.selectEnd()
}
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
/** Editor update that removes submitted content while retaining any newer draft suffix. */
export const DRAFT_SUBMITTED_TAG = 'dsh-draft-submitted'
/** One model-owned editor; registration and disposal remain with its model. */
export class DraftEditorRuntime {
/** The editor bound by the Composer's contenteditable host. */
readonly editor: LexicalEditor
private projected: EditorProjection = {
detectText: '', clipboardText: '', occurrences: [], quotes: [], selection: null, caret: null, command: undefined,
}
/** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
private readonly occurrenceIds = new Map<NodeKey, number>()
private occurrenceSeq = 0
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
private lexiconSource: ObservableSnapshot<Lexicon> | undefined
/** @param deps - model callbacks used by editor listeners and transforms. */
constructor(private readonly deps: DraftEditorRuntimeDeps) {
this.editor = createEditor({
namespace: 'dsh-composer',
nodes: [ReferenceChipNode, CommandChipNode, TextRefNode, QuoteNode],
onError: (error) => { throw error },
})
}
/**
* Install editor behavior after the model holds this runtime.
* @returns unregister callback that also detaches the editor root.
*/
register(): () => void {
// A retained draft selection must not move keyboard focus back from another control.
const preserveExternalSelection = (): false => {
const root = this.editor.getRootElement()
if (root !== null && !root.contains(root.ownerDocument.activeElement)) {
$addUpdateTag(SKIP_DOM_SELECTION_TAG)
}
return false
}
const unregister = mergeRegister(
registerPlainText(this.editor),
this.editor.registerNodeTransform(QuoteNode, (node) => { node.setLabels(this.deps.quoteLabels()) }),
this.editor.registerCommand(REMOVE_QUOTE_COMMAND, key => this.removeQuote(key), COMMAND_PRIORITY_CRITICAL),
this.editor.registerCommand(KEY_BACKSPACE_COMMAND, event => this.deleteAdjacentQuote(event, true), COMMAND_PRIORITY_BEFORE_CRITICAL),
this.editor.registerCommand(KEY_DELETE_COMMAND, event => this.deleteAdjacentQuote(event, false), COMMAND_PRIORITY_BEFORE_CRITICAL),
this.editor.registerCommand(BLUR_COMMAND, () => {
// Finish this batch before an explicit focus can restore its updated selection.
this.editor.update(preserveExternalSelection, { discrete: true })
return false
}, COMMAND_PRIORITY_CRITICAL),
this.editor.registerCommand(SELECTION_CHANGE_COMMAND, preserveExternalSelection, COMMAND_PRIORITY_CRITICAL),
this.editor.registerNodeTransform(RootNode, preserveExternalSelection),
registerReferenceActivation(this.editor, (source, reference) =>
this.deps.openReference(source, reference)),
registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
this.editor.registerUpdateListener(() => { this.deps.onUpdate() }),
registerCommandChipSeat(this.editor),
registerCommandChipSelection(this.editor),
registerTextRefDecoration(this.editor, () => this.deps.lexicon()),
() => {
this.lexiconOff?.()
this.lexiconOff = undefined
this.lexiconSource = undefined
},
)
return () => {
unregister()
this.editor.setRootElement(null)
}
}
/** The latest committed editor projection. */
get projection(): EditorProjection {
return this.projected
}
/**
* Run one editor edit whose result is observable on return. At the top
* level this is a discrete update. Inside this editor's own update —
* command handlers land here synchronously (space/enter picks, paste) —
* $-functions are already legal, and wrapping them in update() would DEFER
* them past the synchronous bail answer (and a nested discrete throws);
* the body runs directly and the outer update commits it.
* @param fn - the $-edit body.
*/
private applyEdit(fn: () => void, tag?: string): void {
if (this.editor._updating) {
// Nested application joins the enclosing update (the PASTE_COMMAND
// dispatch path always lands here), so the tag attaches to that update.
if (tag !== undefined) $addUpdateTag(tag)
fn()
return
}
this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) })
}
/**
* Reconnect decorations when the optional trigger provider changes.
* The rescan reads the current editor and does not replace its content.
*/
refreshLexiconSubscription(): void {
const lexicon = this.deps.resolveLexicon()
if (lexicon === this.lexiconSource) return
this.lexiconOff?.()
this.lexiconSource = lexicon
this.lexiconOff = lexicon?.subscribe(() => { rescanTextRefs(this.editor) })
rescanTextRefs(this.editor)
}
/**
* Re-project inside the existing editor update callback.
* @returns the projection preceding this read.
*/
refreshProjection(): EditorProjection {
const prev = this.projected
this.projected = this.editor.getEditorState().read(() =>
$projectComposer(key => this.occurrenceIdOf(key)))
return prev
}
private occurrenceIdOf(key: NodeKey): number {
const existing = this.occurrenceIds.get(key)
if (existing !== undefined) return existing
this.occurrenceSeq += 1
this.occurrenceIds.set(key, this.occurrenceSeq)
return this.occurrenceSeq
}
/**
* Replace the whole draft (persisted-draft seed and programmatic writes).
* Placeholder-sanitized; newlines split paragraphs; the caret lands at the
* end. Merged into history so a seed is not an undoable step of its own.
* @param text - the full next draft.
*/
setDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === this.projection.clipboardText && this.projection.occurrences.length === 0 && this.projection.quotes.length === 0) return
this.editor.update(() => {
$getRoot().clear()
$appendParagraphs(clean)
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Add plain text after the current draft as new paragraphs, keeping its text
* and references; an empty draft takes the text as its whole content.
* Placeholder-sanitized; the caret lands at the end; merged into history
* like {@link setDraft}.
* @param text - plain text to add.
*/
appendDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === '') return
if (this.projection.clipboardText === '' && this.projection.occurrences.length === 0) {
this.setDraft(clean)
return
}
this.editor.update(() => { $appendParagraphs(clean) }, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Insert pasted plain text over the current editor selection
* (placeholder-sanitized). The paste event's own default is suppressed by
* the caller; PASTE_TAG makes the paste its own history boundary, so one
* undo never removes both the paste and typing inside the merge window.
* @param text - pasted plain text.
*/
paste(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === '') return
this.applyEdit(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
selection.insertText(clean)
return
}
// No selection yet (never-focused surface): land at the document end,
// growing the first paragraph when the tree is empty.
const root = $getRoot()
if (root.getChildrenSize() === 0) root.append($createParagraphNode())
root.selectEnd().insertText(clean)
}, PASTE_TAG)
}
/**
* The live selection as a detect-coordinate span (menu-launcher synthetic
* hits replace it on pick); an absent selection answers a collapsed span at
* the document end.
* @returns the ordered [start, end) span in detect coordinates.
*/
caretSpan(): { start: number; end: number } {
if (this.projection.selection !== null) return this.projection.selection
const at = this.projection.detectText.length
return { start: at, end: at }
}
/**
* Replace a mapped span without applying the model's phase or revision guards.
* @param span - detect-coordinate range.
* @param text - inserted text.
* @returns whether the range mapped and the edit applied.
*/
replaceText(span: DetectSpan, text: string): boolean {
let applied = false
this.applyEdit(() => {
applied = $replaceDetectSpanWithText(span, text)
})
return applied
}
/**
* Insert an asynchronous text result as one independent undo operation.
* @param span - owner-validated insertion range.
* @param text - text sanitized with the same rules as paste.
* @returns whether the range mapped and the edit applied.
*/
insertAsyncText(span: DetectSpan, text: string): boolean {
let applied = false
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
this.applyEdit(() => { applied = $replaceDetectSpanWithText(span, clean) }, PASTE_TAG)
return applied
}
/**
* Insert a reference chip with the existing trailing-space rule.
* @param span - detect-coordinate range.
* @param ref - reference fields.
* @param tail - the character following the range before editing.
* @returns whether the range mapped and the edit applied.
*/
insertReference(span: DetectSpan, ref: ReferenceInsert, tail: string): boolean {
let applied = false
this.applyEdit(() => {
const nodes = tail === ' '
? [$createReferenceChipNode(ref)]
: [$createReferenceChipNode(ref), $createTextNode(' ')]
applied = $replaceDetectSpanWithNodes(span, nodes)
})
return applied
}
/**
* Replace a span with a command chip followed by the claim token's
* argument separator, unless the separator already follows.
* @param span - detect-coordinate range.
* @param claim - the command claim the chip anchors.
* @param tail - the character following the range before editing.
* @param mergeHistory - merge into the previous history entry (a restored
* draft's chip is part of its seed, not an undoable step).
* @returns whether the range mapped and the edit applied.
*/
insertCommand(span: DetectSpan, claim: CommandClaim, tail: string, mergeHistory = false): boolean {
const separator = claim.token.slice(claim.token.trimEnd().length)
let applied = false
this.applyEdit(() => {
const nodes = separator === '' || tail === separator.charAt(0)
? [$createCommandChipNode(claim)]
: [$createCommandChipNode(claim), $createTextNode(separator)]
applied = $replaceDetectSpanWithNodes(span, nodes)
}, mergeHistory ? HISTORY_MERGE_TAG : undefined)
return applied
}
/**
* Insert an ordered file-reference batch after the live selection without deleting it.
* @param references - validated references in source order.
* @returns whether the live insertion position accepted the batch.
*/
insertFileReferences(references: readonly ReferenceInsert[]): boolean {
if (references.length === 0) return true
let applied = false
this.applyEdit(() => {
const projection = $projectComposer(key => this.occurrenceIdOf(key))
const at = projection.selection?.end ?? projection.detectText.length
const before = projection.detectText.slice(0, at)
const nodes = references.flatMap(ref => [$createReferenceChipNode(ref), $createTextNode(' ')])
if (before !== '' && !/\s$/u.test(before)) nodes.unshift($createTextNode(' '))
applied = $replaceDetectSpanWithNodes({ start: at, end: at }, nodes)
}, PASTE_TAG)
return applied
}
/**
* Insert one quote after the retained draft selection as an independent undo edit.
* @param text - nonblank selected transcript text.
* @param labels - current localized editor controls.
* @returns whether the insertion position accepted the node.
*/
insertQuote(text: string, labels: QuoteNodeLabels): boolean {
let applied = false
this.applyEdit(() => {
const projection = $projectComposer(key => this.occurrenceIdOf(key))
const at = projection.selection?.end ?? projection.detectText.length
applied = $replaceDetectSpanWithNodes({ start: at, end: at }, [$createQuoteNode(text, labels), $createTextNode(' ')])
}, PASTE_TAG)
return applied
}
/** Remove one quote node and leave the editor caret at its former position. */
private removeQuote(key: NodeKey): boolean {
const node = $getNodeByKey(key)
if (!$isQuoteNode(node)) return false
const parent = node.getParent()
const offset = node.getIndexWithinParent()
const separator = node.getNextSibling()
$addUpdateTag(HISTORY_PUSH_TAG)
if ($isTextNode(separator) && separator.getTextContent().startsWith(' ')) {
const remaining = separator.getTextContent().slice(1)
if (remaining === '') separator.remove()
else separator.setTextContent(remaining)
}
node.remove()
const caret = Math.min(offset, parent?.getChildrenSize() ?? 0)
parent?.select(caret, caret)
queueMicrotask(() => { this.editor.getRootElement()?.focus({ preventScroll: true }) })
return true
}
/** Delete the QuoteNode immediately beside one collapsed caret. */
private deleteAdjacentQuote(event: KeyboardEvent, backward: boolean): boolean {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
let adjacent = $getAdjacentNode(selection.anchor, backward)
if (!$isQuoteNode(adjacent) && backward && selection.anchor.type === 'text') {
const anchor = selection.anchor.getNode()
if ($isTextNode(anchor)
&& selection.anchor.offset === 1
&& anchor.getTextContent().startsWith(' ')) {
adjacent = anchor.getPreviousSibling()
}
}
if (!$isQuoteNode(adjacent)) return false
event.preventDefault()
return this.removeQuote(adjacent.getKey())
}
/**
* Clear committed content using the model's suffix decision inside the editor update.
* @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root.
*/
clearCommittedDraft(prefixLength: (clipboardText: string) => number | null): void {
this.editor.update(() => {
const layout = $composerLayout()
const length = prefixLength(layout.clipboardText)
if (length !== null) {
$replaceDetectSpanWithText(
{ start: 0, end: detectOffsetOfClipboardOffset(layout, length) }, '',
)
return
}
const root = $getRoot()
root.clear()
root.selectEnd()
}, { discrete: true, tag: [HISTORY_MERGE_TAG, DRAFT_SUBMITTED_TAG] })
}
/**
* Import semantic content or a model-selected failure snapshot with fresh reference nodes.
* @param draft - clipboard text.
* @param occurrences - reference occurrences in clipboard order.
* @param quotes - atomic quotes in clipboard order.
* @param labels - localized quote controls.
*/
restoreDraft(draft: string, occurrences: readonly DraftReference[], quotes: readonly DraftQuote[] = [], labels: QuoteNodeLabels = { remove: '' }): void {
this.editor.update(() => {
const root = $getRoot()
root.clear()
let paragraph = $createParagraphNode()
root.append(paragraph)
const appendText = (text: string): void => {
const lines = text.replace(REFERENCE_PLACEHOLDER_RE, '').split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (line !== '') paragraph.append($createTextNode(line))
if (i < lines.length - 1) {
paragraph = $createParagraphNode()
root.append(paragraph)
}
}
}
let cursor = 0
const spans = [
...occurrences.map(reference => ({ kind: 'reference' as const, value: reference })),
...quotes.map(quote => ({ kind: 'quote' as const, value: quote })),
].sort((a, b) => a.value.offset - b.value.offset)
for (const span of spans) {
const occurrence = span.value
appendText(draft.slice(cursor, occurrence.offset))
if (span.kind === 'quote') {
paragraph.append($createQuoteNode(span.value.text, labels))
cursor = occurrence.offset + occurrence.length
continue
}
const reference = span.value
paragraph.append(new ReferenceChipNode({
source: reference.source,
ref: reference.ref,
label: reference.label,
...(reference.appearance === undefined ? {} : { appearance: reference.appearance }),
clipboardText: reference.clipboardText,
}, reference.invalid === true))
cursor = occurrence.offset + occurrence.length
}
appendText(draft.slice(cursor))
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/** Cut the editor's undo history after a committed clear or restoration. */
clearHistory(): void {
this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
}
}