import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ComposerDecorationContext, ComposerEditResult, ComposerNativeRange, ComposerSurfaceExtensionPresentation, ComposerTextSegment, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InputActions, InputState } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-store' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots' import type { BetterComposerSettingsStore } from './settings-store.ts' import { createDecorationProvider } from './decoration-provider.ts' import { createComposerVisualController } from './visual-controller.ts' import { convertCapturedPasteToClip, type ClipSessionInput } from './clip-convert.ts' import { installPasteInterceptor } from './paste-interceptor.ts' import type { ClipStore } from './clip-store.ts' import { createMarkdownSurfaceExtension } from './surface-extension.ts' import { applyComposerEdit } from './edit-apply.ts' import { installComposerKeymap } from './keys.ts' import { subscribeGrammarLoaded } from '../markdown/primitives.ts' import { useComposerPresentation } from './selection.ts' import { getClipToast, subscribeClipToast } from './clip-toast.ts' import { codeCopyBlockAnchors, codeCopyBlocksForSegments, codeFenceLanguageEdit, tableLayoutsForSegments, tableRowAnchors, type MarkdownCodeCopyAnchor, type MarkdownCodeCopyBlock, type MarkdownTableLayout, type MarkdownTableRow, } from './presentation-layout.ts' const useSafeLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect /** * Coalesce burst event sources (resize/ResizeObserver) into one measure * per frame: each measure walks the full input DOM, so unthrottled bursts on * long drafts cause layout thrash. */ function rafSchedule(measure: () => void): { schedule: () => void; cancel: () => void } { let frame = 0 return { schedule: () => { if (frame !== 0) return frame = requestAnimationFrame(() => { frame = 0 measure() }) }, cancel: () => { if (frame !== 0) cancelAnimationFrame(frame) frame = 0 }, } } /** Skip the React commit when a re-measure produced identical geometry. */ function samePositions(previous: readonly T[], next: readonly T[], signature: (position: T) => string): boolean { return previous.length === next.length && previous.every((position, index) => signature(position) === signature(next[index]!)) } /** Standard session props the overlay slot receives from the renderer. */ export interface BetterComposerOverlayProps { readonly sessionId: string readonly useInput: SnapshotSelectorHook readonly inputActions: InputActions } /** * Find the Core-owned contenteditable from one overlay layer. The slot root * is a sibling of `[data-composer-input]` (not its ancestor), and Core may * wrap slot content in arbitrary containers, so climb until an ancestor * contains the input. */ export function findComposerInput(layer: HTMLElement | null): HTMLElement | null { let node = layer?.parentElement ?? null while (node !== null) { const input = node.querySelector('[data-composer-input]') if (input !== null) return input node = node.parentElement } return null } /** Place the visual layer beside the input, inside Core's scrolling content. */ function useComposerFrame(input: HTMLElement | null): HTMLElement | null { const [frame, setFrame] = useState(null) useLayoutEffect(() => { const candidate = input?.parentElement ?? null setFrame(candidate) if (candidate === null) return const hadAttribute = candidate.hasAttribute('data-better-composer-visual-anchor') candidate.setAttribute('data-better-composer-visual-anchor', 'true') return () => { if (!hadAttribute) candidate.removeAttribute('data-better-composer-visual-anchor') } }, [input]) return frame } /** Resolve the contenteditable, tolerating first-commit sibling ordering. */ function useComposerInput(layer: HTMLElement | null): HTMLElement | null { const [input, setInput] = useState(null) useLayoutEffect(() => { if (layer === null) { setInput(null) return } const found = findComposerInput(layer) setInput(previous => (previous === found ? previous : found)) if (found !== null) return const host = layer.closest('[data-composer-card]') ?? layer.parentElement if (host === null) return const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(() => { const candidate = findComposerInput(layer) if (candidate !== null) { setInput(candidate) observer?.disconnect() } }) observer?.observe(host, { childList: true, subtree: true }) return () => { observer?.disconnect() } }, [layer]) return input } /** Resolve the per-session input facade (state/setDraft/notify) once per session. */ function useSessionInput(ctx: ClientContext, sessionId: string): ClipSessionInput | undefined { return useMemo(() => { try { const sessions = ctx.sessions as { scope?: (id: never) => unknown } | undefined const actx = sessions?.scope?.(sessionId as never) if (actx === undefined || actx === null) return undefined const conversation = ctx.conversation as { input?: { for?: (scope: unknown) => ClipSessionInput } } | undefined return conversation?.input?.for?.(actx) } catch { return undefined } }, [ctx, sessionId]) } /** Ordinary text segments between chip occurrences, in source order. */ function textSegmentsFor(draft: string, nativeRanges: readonly ComposerNativeRange[]): readonly ComposerTextSegment[] { const segments: ComposerTextSegment[] = [] let cursor = 0 for (const range of [...nativeRanges].sort((left, right) => left.start - right.start)) { if (range.start > cursor) segments.push({ start: cursor, end: range.start, text: draft.slice(cursor, range.start) }) cursor = Math.max(cursor, range.end) } if (cursor < draft.length) segments.push({ start: cursor, end: draft.length, text: draft.slice(cursor) }) return segments } /** * Build the session-scoped `conversation.input.overlay` contribution. The * returned component derives the whole decoration context from the official * input state, tracks presentation from DOM signals, self-paints Markdown * highlights, consumes its own keys in the capture phase, and converts long * pastes into clips. */ export function createEditorContribution( ctx: ClientContext, settings: BetterComposerSettingsStore, clips: ClipStore, ): SlotComponent { return function EditorContribution(props: BetterComposerOverlayProps) { return } } function EditorOverlay({ sessionId, useInput, inputActions, ctx, settings, clips }: BetterComposerOverlayProps & { readonly ctx: ClientContext readonly settings: BetterComposerSettingsStore readonly clips: ClipStore }) { const current = useSyncExternalStore( listener => settings.subscribe(listener), () => settings.get(), () => settings.get(), ) const inputState = useInput((state: InputState) => state) const layerRef = useRef(null) const [layer, setLayer] = useState(null) const setLayerRef = useCallback((element: HTMLDivElement | null) => { layerRef.current = element setLayer(previous => (previous === element ? previous : element)) }, []) const input = useComposerInput(layer) const sessionInput = useSessionInput(ctx, sessionId) const nativeRanges = useMemo(() => inputState.occurrences.map((occurrence: { readonly offset: number; readonly length: number; readonly source: string }) => ({ start: occurrence.offset, end: occurrence.offset + occurrence.length, kind: occurrence.source, })), [inputState.occurrences]) const textSegments = useMemo(() => textSegmentsFor(inputState.draft, nativeRanges), [inputState.draft, nativeRanges]) const sourceRef = useRef({ draft: inputState.draft, nativeRanges }) sourceRef.current = { draft: inputState.draft, nativeRanges } const presentation = useComposerPresentation(input, sourceRef) const surfaceContext = useMemo(() => ({ draft: inputState.draft, draftRev: inputState.draftRev, selection: { start: presentation.selectionStart, end: presentation.selectionEnd }, nativeRanges, composing: presentation.composing === true, triggerOwner: inputState.phase === 'claimed' ? 'slash' as const : 'none' as const, textSegments, }), [inputState.draft, inputState.draftRev, inputState.phase, nativeRanges, textSegments, presentation]) const extension = useMemo(() => createMarkdownSurfaceExtension(settings), [settings]) const surfacePresentation = current.enabled ? extension.present(surfaceContext) : undefined const provider = useMemo(() => createDecorationProvider(() => settings.get()), [settings]) const apply = useCallback((edit: ComposerEditResult, draftRev: number): boolean => applyComposerEdit(inputActions, inputState.occurrences, edit, draftRev), [inputActions, inputState.occurrences]) // Paste capture runs before Core's default insertion. Long pastes become a // real reference chip while the captured revision/span is still current; // ordinary multiline text uses Core's public setDraft seam to rebuild one // paragraph per line. useEffect(() => { if (!current.enabled || input === null || sessionInput === undefined) return () => {} return installPasteInterceptor({ input, actions: inputActions, readState: () => ({ draft: inputState.draft, occurrences: inputState.occurrences.length, phase: inputState.phase, composing: presentation.composing === true, threshold: current.pasteClipThreshold, }), onClip: (text, span) => convertCapturedPasteToClip( ctx, clips, String(sessionId), text, span, sessionInput, ), }) }, [ctx, clips, sessionId, sessionInput, input, inputActions, inputState.draft, inputState.occurrences.length, inputState.phase, presentation.composing, current.enabled, current.pasteClipThreshold]) // Self-paint: compute text and block decoration segments and install them // onto the editor root and direct Lexical blocks. const [grammarTick, setGrammarTick] = useState(0) useEffect(() => subscribeGrammarLoaded(() => { setGrammarTick(tick => tick + 1) }), []) const decorationContext = useMemo(() => ({ sessionId: String(sessionId) as ComposerDecorationContext['sessionId'], draft: inputState.draft, draftRev: inputState.draftRev, nativeRanges, textSegments, presentation, }), [sessionId, inputState.draft, inputState.draftRev, nativeRanges, textSegments, presentation]) const visualControllerRef = useRef | null>(null) useSafeLayoutEffect(() => { if (input === null || !current.enabled) { visualControllerRef.current?.dispose() visualControllerRef.current = null return () => {} } const controller = createComposerVisualController(input) visualControllerRef.current = controller return () => { if (visualControllerRef.current === controller) visualControllerRef.current = null controller.dispose() } }, [input, current.enabled]) useSafeLayoutEffect(() => { const controller = visualControllerRef.current if (controller === null || !current.enabled) return controller.update(provider, decorationContext) }, [provider, decorationContext, current.enabled, current.markdownVisual, current.diagnostics, grammarTick]) // Capture only completion and list-structure keys; formatting shortcuts stay with Core. useEffect(() => { if (input === null) return () => {} return installComposerKeymap({ input: () => input, surfaceContext: () => surfaceContext, presentation: () => surfacePresentation, extension, apply, isEnabled: () => settings.get().enabled, }) }, [input, surfaceContext, surfacePresentation, extension, apply, settings]) // Toast feedback: auto-dismisses after 4 seconds. const toastMessage = useSyncExternalStore(subscribeClipToast, getClipToast, () => null) // Floating presentation layers (table grid, code controls) derived from the // same authoritative segments; they mount only when Markdown paint is on. const tableLayouts = useMemo( () => current.enabled && current.markdownVisual ? tableLayoutsForSegments(textSegments) : [], [current.enabled, current.markdownVisual, textSegments], ) const codeBlocks = useMemo( () => current.enabled && current.markdownVisual ? codeCopyBlocksForSegments(textSegments) : [], [current.enabled, current.markdownVisual, textSegments], ) if (!current.enabled) return null return } /** * Live caret position in frame content coordinates, measured from the DOM * selection. Because the frame rides inside the scrolling content, these * coordinates stay valid while the scrollport scrolls; re-measure on * selection change, resize, and frame geometry change. */ function useCaretAnchor( frame: HTMLElement | null, active: boolean, ): { readonly top: number; readonly left: number } | undefined { const [position, setPosition] = useState<{ top: number; left: number } | undefined>(undefined) useSafeLayoutEffect(() => { if (!active || frame === null) { setPosition(previous => (previous === undefined ? previous : undefined)) return () => {} } const measure = (): void => { const selection = document.getSelection() if (selection === null || selection.rangeCount === 0) return let rect = selection.getRangeAt(0).getBoundingClientRect() if (rect.height === 0 && rect.width === 0) { const anchor = selection.anchorNode const element = anchor instanceof HTMLElement ? anchor : anchor?.parentElement if (element === null || element === undefined) return rect = element.getBoundingClientRect() } const frameRect = frame.getBoundingClientRect() const next = { top: rect.bottom - frameRect.top, left: rect.left - frameRect.left } setPosition(previous => previous !== undefined && previous.top === next.top && previous.left === next.left ? previous : next) } measure() const { schedule, cancel } = rafSchedule(measure) schedule() window.addEventListener('resize', schedule) document.addEventListener('selectionchange', schedule) const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule) observer?.observe(frame) return () => { cancel() window.removeEventListener('resize', schedule) document.removeEventListener('selectionchange', schedule) observer?.disconnect() } }, [frame, active]) return active ? position : undefined } /** * Completion presentation anchored at the caret inside the scrolling content. * The popup opens below the caret line and flips above it when the scrollport * would clip it; the ghost rides inline at the caret. */ function CompletionLayer({ frame, popup, ghost, activeOptionId, }: { readonly frame: HTMLElement readonly popup: CompletionPopupState | undefined readonly ghost: CompletionGhostState | undefined readonly activeOptionId: string | undefined }) { const active = popup !== undefined || ghost !== undefined const anchor = useCaretAnchor(frame, active) const layerRef = useRef(null) const [popupTop, setPopupTop] = useState(undefined) useEffect(() => { if (!active) setPopupTop(undefined) }, [active]) useSafeLayoutEffect(() => { const layer = layerRef.current if (layer === null || anchor === undefined) return const scroller = (frame.closest('[data-input-scroll]') as HTMLElement | null) ?? frame const limit = scroller.getBoundingClientRect().bottom - frame.getBoundingClientRect().top let height = 0 for (const child of layer.children) { height = Math.max(height, child.getBoundingClientRect().height) } const below = anchor.top + 2 const next = below + height > limit ? Math.max(0, anchor.top - height - 4) : below setPopupTop(previous => (previous === next ? previous : next)) }, [frame, anchor, popup, ghost]) if (!active || anchor === undefined) return null return (
{ghost !== undefined ? : null} {popup !== undefined ? ( ) : null}
) } type CompletionPopupState = NonNullable type CompletionGhostState = NonNullable /** The caret-anchored candidate list; positions are frame content coordinates. */ export function CompletionPopup({ popup, top, left, activeOptionId, }: { readonly popup: CompletionPopupState readonly top: number readonly left: number readonly activeOptionId: string | undefined }) { return (
    {popup.candidates.map((candidate, index) => (
  • {candidate.label}
  • ))}
) } /** The caret-anchored inline suffix suggestion; aria-hidden presentation only. */ export function CompletionGhost({ ghost, top, left, }: { readonly ghost: CompletionGhostState readonly top: number readonly left: number }) { return ( {ghost.insertText} ) } export function EditorSurface({ surfacePresentation, toastMessage, setLayerRef, input, context, apply, tableLayouts, codeBlocks, }: { readonly surfacePresentation: ComposerSurfaceExtensionPresentation | undefined readonly toastMessage: string | null readonly setLayerRef: (element: HTMLDivElement | null) => void readonly input: HTMLElement | null readonly context: ComposerDecorationContext readonly apply: (edit: ComposerEditResult, draftRev: number) => boolean readonly tableLayouts: readonly MarkdownTableLayout[] readonly codeBlocks: readonly MarkdownCodeCopyBlock[] }) { const frame = useComposerFrame(input) const diagnostics = surfacePresentation?.diagnostics ?? [] const popup = surfacePresentation?.popup const ghost = surfacePresentation?.ghost const hints = surfacePresentation?.hints ?? [] const activeOptionId = popup !== undefined && popup.candidates[popup.selectedIndex] !== undefined ? completionOptionId(popup.revision, popup.selectedIndex) : undefined return (
{toastMessage !== null ? (
{toastMessage}
) : null} {diagnostics.length > 0 ? (
    {diagnostics.map((diagnostic, index) => (
  • {diagnostic.message}
  • ))}
) : null} {hints.length > 0 ? (
    {hints.map((hint, index) => (
  • {hint.message}
  • ))}
) : null} {frame !== null && input !== null && (tableLayouts.length > 0 || codeBlocks.length > 0) ? createPortal(
{tableLayouts.length > 0 ? : null} {codeBlocks.length > 0 ? : null}
, frame, ) : null} {frame !== null && (popup !== undefined || ghost !== undefined) ? createPortal( , frame, ) : null}
) } function completionOptionId(revision: number, index: number): string { return `dsh-better-composer-completion-option-${revision}-${index}` } interface TableRowPosition { readonly layoutStart: number readonly row: MarkdownTableRow readonly top: number readonly left: number readonly width: number readonly height: number readonly columns: string readonly columnCount: number } /** * Presentation-only table grid. Rows are cloned geometry over the Core-owned * source lines inside the input's scrolling content. The scrollport clips * rows without a scroll-event re-measure. */ function TableVisualRows({ frame, input, context, layouts, }: { readonly frame: HTMLElement readonly input: HTMLElement readonly context: ComposerDecorationContext readonly layouts: readonly MarkdownTableLayout[] }) { const layerRef = useRef(null) const [positions, setPositions] = useState([]) useSafeLayoutEffect(() => { if (layerRef.current === null) return () => {} const measure = (): void => { const inactiveLayouts = layouts.filter(layout => !tableIsActive(context, layout)) const anchors = tableRowAnchors(input, context.draft, context.nativeRanges, inactiveLayouts) if (anchors === undefined) return if (anchors.length === 0) { setPositions(previous => previous.length === 0 ? previous : []) return } const frameRect = frame.getBoundingClientRect() const next = anchors.flatMap(anchor => { const rect = anchor.element.getBoundingClientRect() if (rect.width === 0 && rect.height === 0) return [] const layout = inactiveLayouts.find(candidate => candidate.start === anchor.layoutStart) if (layout === undefined) return [] return [{ layoutStart: anchor.layoutStart, row: anchor.row, top: rect.top - frameRect.top, left: rect.left - frameRect.left, width: rect.width, height: rect.height, columns: layout.columnWidths.map(width => `minmax(0, ${width}fr)`).join(' '), columnCount: layout.columnWidths.length, }] }) setPositions(previous => samePositions(previous, next, position => `${position.row.start}:${position.top}:${position.left}:${position.width}:${position.height}:${position.columns}`) ? previous : next) } measure() const { schedule, cancel } = rafSchedule(measure) // The structural table-row classes land in the parent's layout effect, // after this child effect; re-measure once per frame to catch them. schedule() window.addEventListener('resize', schedule) const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule) observer?.observe(input) observer?.observe(frame) return () => { cancel() window.removeEventListener('resize', schedule) observer?.disconnect() } }, [frame, input, context, layouts]) return
{positions.map(position => { const cells = Array.from({ length: position.columnCount }, (_, index) => position.row.cells[index] ?? '') return
{cells.map((cell, index) => {cell})}
})}
} function tableIsActive(context: ComposerDecorationContext, layout: MarkdownTableLayout): boolean { const presentation = context.presentation if (presentation?.focused !== true) return false const { selectionStart, selectionEnd } = presentation if (selectionStart === selectionEnd) return layout.start <= selectionStart && selectionStart < layout.end return selectionStart < layout.end && layout.start < selectionEnd } const CODE_LANGUAGES = [ { value: '', label: '纯文本' }, { value: 'js', label: 'JavaScript' }, { value: 'ts', label: 'TypeScript' }, { value: 'python', label: 'Python' }, { value: 'bash', label: 'Bash' }, { value: 'json', label: 'JSON' }, { value: 'html', label: 'HTML' }, { value: 'css', label: 'CSS' }, { value: 'markdown', label: 'Markdown' }, ] as const interface CodeControlPosition { readonly start: number readonly top: number readonly languageLeft: number readonly copyLeft: number } /** * Code-block language select and copy button, positioned in the input's * scrolling content and clipped by the scrollport. The select rewrites the * fence info string through the revision-guarded transaction bridge; the copy * button reads the precomputed body and never touches the draft. */ function CodeBlockControls({ frame, input, context, blocks, apply, }: { readonly frame: HTMLElement readonly input: HTMLElement readonly context: ComposerDecorationContext readonly blocks: readonly MarkdownCodeCopyBlock[] readonly apply: (edit: ComposerEditResult, draftRev: number) => boolean }) { const layerRef = useRef(null) const anchorsRef = useRef([]) const [positions, setPositions] = useState([]) const [hoveredStart, setHoveredStart] = useState() const [copiedStart, setCopiedStart] = useState() useSafeLayoutEffect(() => { if (layerRef.current === null) return () => {} const measure = (): void => { const anchors = codeCopyBlockAnchors(input, context.draft, context.nativeRanges, blocks) if (anchors === undefined) return anchorsRef.current = anchors if (anchors.length === 0) { setPositions(previous => previous.length === 0 ? previous : []) return } const frameRect = frame.getBoundingClientRect() const next = anchors.flatMap(anchor => { const rect = anchor.element.getBoundingClientRect() if (rect.width === 0 && rect.height === 0) return [] return [{ start: anchor.start, top: rect.top - frameRect.top + 8, languageLeft: rect.left - frameRect.left + 12, copyLeft: rect.right - frameRect.left - 36, }] }) setPositions(previous => samePositions(previous, next, position => `${position.start}:${position.top}:${position.languageLeft}:${position.copyLeft}`) ? previous : next) } measure() const { schedule, cancel } = rafSchedule(measure) schedule() window.addEventListener('resize', schedule) const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule) observer?.observe(input) observer?.observe(frame) return () => { cancel() window.removeEventListener('resize', schedule) observer?.disconnect() } }, [frame, input, blocks, context]) useEffect(() => { const layer = layerRef.current if (layer === null) return () => {} const onPointerOver = (event: Event): void => { const target = event.target if (!(target instanceof Element)) return const block = target.closest('.dsh-better-composer-code-block') const anchor = anchorsRef.current.find(candidate => candidate.element === block) if (anchor !== undefined) setHoveredStart(anchor.start) } const onPointerOut = (event: Event): void => { const related = (event as PointerEvent).relatedTarget if (related instanceof Node && (input.contains(related) || layer.contains(related))) return setHoveredStart(undefined) } input.addEventListener('pointerover', onPointerOver) input.addEventListener('pointerout', onPointerOut) return () => { input.removeEventListener('pointerover', onPointerOver) input.removeEventListener('pointerout', onPointerOut) } }, [input, blocks, context]) return
{positions.map(position => { const block = blocks.find(candidate => candidate.start === position.start) if (block === undefined) return null const visible = hoveredStart === position.start const options = CODE_LANGUAGES.some(option => option.value === block.language) ? CODE_LANGUAGES : [...CODE_LANGUAGES, { value: block.language, label: block.language }] return
})}
}