Merge pull request #427 from eg-bole/fix/selection-popup-dismiss-cursor-insert

fix(editor/conversation): selection popup dismissal + caret-anchored draft insert (#425)
This commit is contained in:
Atlas Zeng
2026-09-02 23:09:15 +08:00
committed by GitHub
5 changed files with 785 additions and 60 deletions
+32 -52
View File
@@ -28,6 +28,7 @@ import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { useSelectionPopup } from './selection-popup.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { analyzeMarkdownHtml } from './markdown-html.ts'
@@ -41,13 +42,6 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/** The floating "add to conversation" action: payload + viewport anchor. */
interface SelectionPopup {
insert: string
left: number
top: number
}
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
@@ -71,36 +65,23 @@ export function TextEditor(props: FileViewerProps) {
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror of the popup state for click-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
const hidePopup = (): void => {
popupRef.current = null
setPopup(null)
}
/** Anchor the popup above the selection center; clamp inside the viewport. */
const showPopup = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
/** The popup button's click: insert the stored payload into the draft. */
const commitPopup = (): void => {
const current = popupRef.current
if (current === null) return
appendToDraft(ctx, scope.sessionId, current.insert)
hidePopup()
}
/**
* The floating "add to conversation" popup (viewport-anchored; null =
* hidden). The hook owns show/hide/commit plus the global dismissal
* listeners (outside mousedown, Escape, hidden tab/window, surface
* leaving the viewport) — see selection-popup.ts.
*/
const selectionPopup = useSelectionPopup({
onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
// The surface that must stay on screen: the markdown preview container
// in preview mode, the CodeMirror host otherwise.
getSurface: () => (markdown && mode === 'preview' ? mdRef.current : hostRef.current),
})
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
@@ -110,7 +91,7 @@ export function TextEditor(props: FileViewerProps) {
setDraft(null)
setDirty(false)
setSaveState('idle')
hidePopup()
selectionPopup.hide()
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
@@ -160,33 +141,33 @@ export function TextEditor(props: FileViewerProps) {
...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
selectionPopup.hide()
return
}
if (!update.view.hasFocus) {
hidePopup()
selectionPopup.hide()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
hidePopup()
selectionPopup.hide()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
hidePopup()
selectionPopup.hide()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
hidePopup()
selectionPopup.hide()
return
}
const doc = update.state.doc
showPopup(
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
@@ -223,7 +204,7 @@ export function TextEditor(props: FileViewerProps) {
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
useEffect(() => {
hidePopup()
selectionPopup.hide()
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
@@ -254,8 +235,6 @@ export function TextEditor(props: FileViewerProps) {
})
}
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** The preview source: `mdText` with local image destinations rewritten to
@@ -307,22 +286,22 @@ export function TextEditor(props: FileViewerProps) {
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
hidePopup()
selectionPopup.hide()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
hidePopup()
selectionPopup.hide()
return
}
const text = sel.toString()
if (text.trim() === '') {
hidePopup()
selectionPopup.hide()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(mdText, text)
showPopup(
selectionPopup.show(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
@@ -413,7 +392,7 @@ export function TextEditor(props: FileViewerProps) {
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
onScroll={selectionPopup.hide}
>
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
@@ -461,15 +440,16 @@ export function TextEditor(props: FileViewerProps) {
/>
</>
)}
{popup !== null && createPortal(
{selectionPopup.popup !== null && createPortal(
<button
type="button"
ref={selectionPopup.buttonRef}
className={css.selectionPopup}
style={{ left: popup.left, top: popup.top }}
style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={commitPopup}
onClick={selectionPopup.commit}
>
{t('addToConversation')}
</button>,
+166 -8
View File
@@ -1,29 +1,187 @@
/**
* Append text to the current session's composer draft through the
* Insert text into the current session's composer draft through the
* conversation service — the shared path behind the explorer's @-reference
* button and the viewer selection popup. The service is resolved lazily
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*
* Insert position (fixes upstream issue #425): the draft store only exposes
* the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
* caret API on the conversation service. The composer's `<textarea>` keeps
* its last selection even while unfocused, so the live caret is probed from
* the DOM (guarded by a value-sync check), and the text is spliced at that
* position, replacing any live selection — with whitespace-aware joins, an
* insert into the middle of a sentence keeps single-space separation like
* the append path. An unknown/stale caret falls back to appending at the
* end (the pre-fix behavior).
*
* The caret is also *restored* after the insert: committing a programmatic
* draft change resets the controlled textarea's caret (observed landing at
* the start of the value), which would make every later insert probe the
* reset position and drift the stack (A|B + C + D ended up as |DACB). The
* placement (`placeComposerCaretAfterInsert`) puts the caret right after the
* inserted text once the value commit lands — the index accounts for the
* separating space on the left, so stacked inserts stay at their running
* position (A|B + C + D → ACD|B).
*/
import type { Context, SidebarConversation } from '../context-types.ts'
/** A resolved composer caret/selection in draft coordinates. */
export interface DraftCaret {
start: number
end: number
}
/**
* Append `text` to the session's composer draft (space-separated, like the
* @-mentions). Returns false — and logs — when the conversation service or
* the session scope is unavailable.
* The spliced draft plus the caret index (in that draft) right after the
* inserted text — the left-side separating space shifts the caret by one,
* which naive `start + text.length` misses (it would land before the tail).
*/
interface SpliceResult {
draft: string
caretAfter: number
}
/**
* Splice `text` into `draft` at `caret` (replacing any live selection) with
* whitespace-aware joins and report the caret position right after the
* inserted text. `caret === null` (position unknown) appends at the end,
* exactly like the original behavior.
*/
function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult {
if (caret === null || draft === '') {
const next = draft.trim() === '' ? text : `${draft} ${text}`
return { draft: next, caretAfter: next.length }
}
const prefix = draft.slice(0, caret.start)
const suffix = draft.slice(caret.end)
if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length }
// One separating space, but never doubled against adjacent whitespace
// (or the string edges) — mirrors how typing in the middle of a sentence
// behaves.
const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
const right = suffix === '' || /^\s/.test(suffix) ? '' : ' '
return {
draft: `${prefix}${left}${text}${right}${suffix}`,
caretAfter: prefix.length + left.length + text.length,
}
}
/**
* The spliced draft string (see {@link spliceInsert}); pure string math —
* unit-tested directly.
*/
export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string {
return spliceInsert(draft, text, caret).draft
}
/**
* Locate the composer `<textarea>` in the conversation column: prefer the
* `data-phase`-tagged textarea (the composer's marker), falling back to any
* textarea in the column, then to a bare data-phase textarea (older host
* layouts without the column attribute). Null in jsdom-less hosts.
*/
function findComposerTextarea(): HTMLTextAreaElement | null {
if (typeof document === 'undefined') return null
const column = document.querySelector('#root [data-slot="conversation"]')
const find = (scope: ParentNode): HTMLTextAreaElement | null =>
scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
return column !== null
? find(column)
: document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
}
/**
* Resolve the composer's live caret from its DOM `<textarea>`. The draft
* store has no caret API, so the sidebar reads the composed input's selection
* directly; the value-sync check (`el.value === draft`) discards stale or
* wrong-composer reads — a caret must never be applied against a draft it
* was not measured on.
*
* Returns null when the composer is missing, disabled/read-only, out of
* sync with the store draft, or has no measurable selection (jsdom/odd
* hosts report null selectionStart/End).
*/
export function probeComposerCaret(draft: string): DraftCaret | null {
const el = findComposerTextarea()
if (el === null || el.disabled || el.readOnly) return null
if (el.value !== draft) return null
let start = el.selectionStart
let end = el.selectionEnd
if (typeof start !== 'number' || typeof end !== 'number') return null
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
start = Math.max(0, Math.min(start, draft.length))
end = Math.max(start, Math.min(end, draft.length))
return { start, end }
}
/**
* Restore the composer caret to `caretIndex` after a programmatic
* `setDraft` commit. A controlled textarea update resets the caret (React
* commits the value asynchronously and the browser moves the caret to the
* start/end), so the placement is scheduled and retried across at most two
* animation frames (setTimeout fallback for jsdom), and only applied when
* the textarea still matches `expectedDraft` — a newer edit or a different
* composer wins the race untouched. The caret is clamped into the value
* bounds, mirroring how browsers clamp type-in positions.
*/
export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
let remaining = 2
let scheduled = false
const schedule = (fn: () => void): void => {
if (scheduled) return
scheduled = true
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
else setTimeout(fn, 0)
}
const place = (): void => {
scheduled = false
if (remaining <= 0) return
remaining -= 1
const el = findComposerTextarea()
if (el === null || el.disabled || el.readOnly) return
if (el.value !== expectedDraft) {
// The commit has not landed yet (or a competing edit won) — one more
// frame before giving up.
schedule(place)
return
}
const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
el.setSelectionRange(clamped, clamped)
}
schedule(place)
}
/**
* Insert `text` into the session's composer draft at the composer's live
* caret (see {@link probeComposerCaret}), falling back to appending at the
* end when the caret cannot be resolved. Returns false — and logs — when the
* conversation service or the session scope is unavailable.
*/
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
if (actx === undefined) {
console.warn('[dsh-better-sidebar] draft insert skipped: no session scope', sessionId)
return false
}
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
if (conversation === undefined) {
console.warn('[dsh-better-sidebar] draft insert skipped: conversation service unavailable')
return false
}
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
const caret = probeComposerCaret(draft)
const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
input.setDraft(next)
// Put the caret right after the inserted text once the value commit
// lands — see the module doc for why this keeps stacked inserts at the
// running position.
placeComposerCaretAfterInsert(next, caretAfter)
return true
} catch (error) {
console.warn('[dsh-better-sidebar] draft insert failed:', error)
return false
}
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* The floating "add selection to conversation" popup shared by the text
* viewers (markdown preview + the catch-all code viewer): a viewport-anchored
* button portaled to `document.body`, kept alive across the selection gesture
* and committed on click.
*
* Dismissal contract (fixes upstream issue #425): the popup must never
* outlive its editor surface. The sidebar keeps every tab MOUNTED — switching
* tabs only flips the pane cell to `display:none` and collapsing the panel
* translates it off-screen — while the portaled `position:fixed` button stays
* pinned to its viewport anchor, ignoring both. The caller already hides on
* surface scrolls, selection collapse and mode flips; this hook adds the
* global dismissal that covers everything else:
*
* - any `mousedown` outside the button (tab bar, composer, another pane,
* the collapse toggle, …) closes it;
* - `Escape` closes it;
* - the document going hidden (`visibilitychange`) or the window losing
* focus closes it;
* - an `IntersectionObserver` on the editor surface closes it as soon as the
* surface leaves the viewport — the tab-switch (`display:none`) and
* panel-collapse (translated off-screen) paths have no DOM events of their
* own, so the geometry signal is the only reliable one.
*
* The button's own `mousedown` is never treated as an outside click: the
* caller preventDefaults it to keep the selection/caret alive until the
* click commits (the hook's capture-phase listener runs first and must not
* hide for it).
*/
import { useEffect, useRef, useState, type RefObject } from 'react'
/** The floating "add to conversation" action: payload + viewport anchor. */
export interface SelectionPopup {
insert: string
left: number
top: number
}
export interface SelectionPopupOptions {
/** Commit the payload into the composer draft (button click). */
onCommit(insert: string): void
/**
* The DOM surface that must stay on screen for the popup to live: the
* markdown preview container in preview mode, the CodeMirror host
* otherwise. Called lazily (refs are null until the content loads).
*/
getSurface(): HTMLElement | null
}
export interface SelectionPopupControls {
/** The current popup (null = hidden). */
popup: SelectionPopup | null
/** Attach to the portaled button element. */
buttonRef: RefObject<HTMLButtonElement>
/** Anchor the popup above a selection (viewport-clamped). */
show(insert: string, left: number, top: number): void
/** Hide the popup (idempotent). */
hide(): void
/** The button's click: commit the stored payload, then hide. */
commit(): void
}
export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
// Latest-callback refs: the dismissal listeners live for the mount's
// lifetime, so they must not capture stale closures across renders.
const onCommitRef = useRef(options.onCommit)
const getSurfaceRef = useRef(options.getSurface)
onCommitRef.current = options.onCommit
getSurfaceRef.current = options.getSurface
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror for click/event-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The portaled button itself (for the outside-click guard). */
const buttonRef = useRef<HTMLButtonElement>(null)
/** The surface visibility observer (created lazily on open). */
const observerRef = useRef<IntersectionObserver | null>(null)
const show = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
const hide = (): void => {
popupRef.current = null
setPopup(null)
}
const commit = (): void => {
const current = popupRef.current
if (current === null) return
onCommitRef.current(current.insert)
hide()
}
// Global dismissal listeners, registered once; every handler reads the
// live popup ref so it no-ops while no popup is open.
useEffect(() => {
const onMouseDown = (event: MouseEvent): void => {
if (popupRef.current === null) return
const button = buttonRef.current
if (button !== null && (button === event.target || button.contains(event.target as Node))) return
hide()
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && popupRef.current !== null) hide()
}
const onVisibilityChange = (): void => {
if (document.hidden && popupRef.current !== null) hide()
}
const onWindowBlur = (): void => {
if (popupRef.current !== null) hide()
}
document.addEventListener('mousedown', onMouseDown, true)
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('blur', onWindowBlur)
return () => {
document.removeEventListener('mousedown', onMouseDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('blur', onWindowBlur)
observerRef.current?.disconnect()
observerRef.current = null
}
}, [])
// Re-target the visibility observer whenever a popup opens: the surface
// refs may have been null at mount (content loads async, mode flips swap
// the preview container for the CodeMirror host), so the surface is only
// trustworthy at open time.
useEffect(() => {
if (popup === null) return
observerRef.current?.disconnect()
observerRef.current = null
if (typeof IntersectionObserver === 'undefined') return
const surface = getSurfaceRef.current()
if (surface === null) return
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) hide()
}
}, { threshold: 0 })
observerRef.current = observer
observer.observe(surface)
// eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean
// flip is the only thing that must re-run this; an anchor move while
// open keeps the same surface.
}, [popup !== null])
return { popup, buttonRef, show, hide, commit }
}