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:
+32
-52
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* conversation-draft spec — the caret-resolved composer insertion (upstream
|
||||
* issue #425 ②). `insertAtCaret` is pure string math (splice with
|
||||
* whitespace-aware joins); `probeComposerCaret` reads the live composer
|
||||
* `<textarea>` selection out of the DOM, guarded by a value-sync check so a
|
||||
* stale or wrong-composer caret is never applied; `placeComposerCaretAfterInsert`
|
||||
* restores the caret right after the inserted text once the setDraft value
|
||||
* commit lands, keeping stacked inserts at their running position.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import {
|
||||
appendToDraft,
|
||||
insertAtCaret,
|
||||
placeComposerCaretAfterInsert,
|
||||
probeComposerCaret,
|
||||
} from '../src/client/conversation-draft.ts'
|
||||
|
||||
/**
|
||||
* Mount a fake composer textarea inside the DSH conversation column
|
||||
* (`#root [data-slot="conversation"] textarea[data-phase]`).
|
||||
*/
|
||||
function mountComposer(draft: string, selectionStart: number, selectionEnd: number): HTMLTextAreaElement {
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
const column = document.createElement('div')
|
||||
column.setAttribute('data-slot', 'conversation')
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.setAttribute('data-phase', 'plain')
|
||||
textarea.value = draft
|
||||
textarea.setSelectionRange(selectionStart, selectionEnd)
|
||||
column.append(textarea)
|
||||
root.append(column)
|
||||
document.body.append(root)
|
||||
return textarea
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** Let the scheduled caret placement (rAF or setTimeout fallback) settle. */
|
||||
const tick = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 30) })
|
||||
|
||||
describe('probeComposerCaret', () => {
|
||||
it('returns the textarea selection when the DOM is in sync with the draft', () => {
|
||||
mountComposer('hello world', 5, 5)
|
||||
expect(probeComposerCaret('hello world')).toEqual({ start: 5, end: 5 })
|
||||
})
|
||||
|
||||
it('reports a live selection range, not just a collapsed caret', () => {
|
||||
mountComposer('hello world', 6, 11)
|
||||
expect(probeComposerCaret('hello world')).toEqual({ start: 6, end: 11 })
|
||||
})
|
||||
|
||||
it('returns null when no composer is mounted', () => {
|
||||
expect(document.querySelector('textarea')).toBeNull()
|
||||
expect(probeComposerCaret('anything')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the composer DOM value is out of sync with the store draft', () => {
|
||||
mountComposer('hello', 0, 0)
|
||||
expect(probeComposerCaret('hello changed on the server')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a disabled composer', () => {
|
||||
const input = mountComposer('hello', 2, 2)
|
||||
input.disabled = true
|
||||
expect(probeComposerCaret('hello')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a read-only composer', () => {
|
||||
const input = mountComposer('hello', 2, 2)
|
||||
input.readOnly = true
|
||||
expect(probeComposerCaret('hello')).toBeNull()
|
||||
})
|
||||
|
||||
it('clamps out-of-range selections into the draft bounds', () => {
|
||||
const input = mountComposer('hi', 0, 0)
|
||||
// Direct property writes (jsdom does not enforce bounds like browsers).
|
||||
input.selectionStart = 5
|
||||
input.selectionEnd = 9
|
||||
expect(probeComposerCaret('hi')).toEqual({ start: 2, end: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertAtCaret', () => {
|
||||
it('appends at the end when the caret is unknown (pre-fix behavior)', () => {
|
||||
expect(insertAtCaret('', 'X', null)).toBe('X')
|
||||
expect(insertAtCaret('hello', 'X', null)).toBe('hello X')
|
||||
expect(insertAtCaret(' ', 'X', null)).toBe('X')
|
||||
})
|
||||
|
||||
it('inserts at the caret in the middle of a sentence with one space each side', () => {
|
||||
expect(insertAtCaret('hello world', 'CODE', { start: 5, end: 5 })).toBe('hello CODE world')
|
||||
})
|
||||
|
||||
it('only adds the separating spaces the neighbors actually need', () => {
|
||||
expect(insertAtCaret('one two', 'CODE', { start: 3, end: 3 })).toBe('one CODE two')
|
||||
// Doubled gap, caret right after the word: the leading space is added,
|
||||
// the two after the caret stay untouched (like typing in the gap).
|
||||
expect(insertAtCaret('one two', 'CODE', { start: 3, end: 3 })).toBe('one CODE two')
|
||||
// Doubled gap, caret on the second space: neither side needs a new
|
||||
// space, both originals flank the insertion.
|
||||
expect(insertAtCaret('one two', 'CODE', { start: 4, end: 4 })).toBe('one CODE two')
|
||||
expect(insertAtCaret('one two ', 'CODE', { start: 8, end: 8 })).toBe('one two CODE')
|
||||
})
|
||||
|
||||
it('inserts at the start without a leading space', () => {
|
||||
expect(insertAtCaret('hello', 'CODE', { start: 0, end: 0 })).toBe('CODE hello')
|
||||
})
|
||||
|
||||
it('inserts at the end with a single trailing space', () => {
|
||||
expect(insertAtCaret('hello', 'CODE', { start: 5, end: 5 })).toBe('hello CODE')
|
||||
})
|
||||
|
||||
it('replaces the live selection', () => {
|
||||
expect(insertAtCaret('a little tale', 'CODE', { start: 2, end: 8 })).toBe('a CODE tale')
|
||||
})
|
||||
|
||||
it('replaces a selection surrounded by words with single-space joins', () => {
|
||||
expect(insertAtCaret('abc def ghi', 'CODE', { start: 4, end: 7 })).toBe('abc CODE ghi')
|
||||
})
|
||||
|
||||
it('replaces a selection spanning the whole draft', () => {
|
||||
expect(insertAtCaret('old', 'CODE', { start: 0, end: 3 })).toBe('CODE')
|
||||
})
|
||||
|
||||
it('handles an empty draft with a resolved caret', () => {
|
||||
expect(insertAtCaret('', 'CODE', { start: 0, end: 0 })).toBe('CODE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeComposerCaretAfterInsert', () => {
|
||||
it('places the caret right after the inserted text once the value commits', async () => {
|
||||
const input = mountComposer('AB', 1, 1) // pre-insert draft still shown
|
||||
placeComposerCaretAfterInsert('A C B', 1 + 1)
|
||||
input.value = 'A C B' // the setDraft commit lands
|
||||
await tick()
|
||||
expect(input.selectionStart).toBe(2)
|
||||
expect(input.selectionEnd).toBe(2)
|
||||
})
|
||||
|
||||
it('places the caret when the value already matches (no commit needed)', async () => {
|
||||
const input = mountComposer('A C B', 0, 0)
|
||||
placeComposerCaretAfterInsert('A C B', 2)
|
||||
await tick()
|
||||
expect(input.selectionStart).toBe(2)
|
||||
})
|
||||
|
||||
it('clamps an out-of-range caret into the composer bounds', async () => {
|
||||
const input = mountComposer('A C B', 0, 0)
|
||||
placeComposerCaretAfterInsert('A C B', 99)
|
||||
await tick()
|
||||
expect(input.selectionStart).toBe(5)
|
||||
})
|
||||
|
||||
it('does not clobber a composer that never matches the expected draft', async () => {
|
||||
const input = mountComposer('OLD', 0, 0)
|
||||
input.value = 'UNRELATED' // a competing update wins the race
|
||||
input.setSelectionRange(1, 1)
|
||||
placeComposerCaretAfterInsert('NEW DRAFT', 2)
|
||||
await tick()
|
||||
expect(input.selectionStart).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves the caret alone when no composer is mounted', async () => {
|
||||
placeComposerCaretAfterInsert('X', 0)
|
||||
await tick() // must not throw
|
||||
expect(document.querySelector('textarea')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToDraft', () => {
|
||||
/** A fake ctx exposing the conversation service face appendToDraft uses. */
|
||||
function fakeCtx(
|
||||
getDraft: () => string,
|
||||
onSet: (next: string) => void,
|
||||
withConversation = true,
|
||||
): Context {
|
||||
const input = {
|
||||
state: { getSnapshot: (): { draft: string } => ({ draft: getDraft() }) },
|
||||
setDraft: (next: string): void => { onSet(next) },
|
||||
}
|
||||
return {
|
||||
sessions: { scope: (): unknown => ({}) },
|
||||
get: (name: string): unknown =>
|
||||
withConversation && name === 'conversation'
|
||||
? { input: { for: (): unknown => input } }
|
||||
: undefined,
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
it('splices at the probed caret and restores the caret right after the insert', async () => {
|
||||
const composer = mountComposer('AB', 1, 1)
|
||||
const calls: string[] = []
|
||||
const ctx = fakeCtx(() => 'AB', (next) => { calls.push(next); composer.value = next })
|
||||
expect(appendToDraft(ctx, 's1', 'C')).toBe(true)
|
||||
expect(calls).toEqual(['A C B'])
|
||||
await tick()
|
||||
// 'A C| B' — the caret sits right after the inserted text (the left
|
||||
// separating space shifts it past `start + text.length`).
|
||||
expect(composer.selectionStart).toBe(3)
|
||||
expect(composer.selectionEnd).toBe(3)
|
||||
})
|
||||
|
||||
it('appends and places the caret at the end when the caret is unknown', async () => {
|
||||
const composer = mountComposer('hi', 0, 0)
|
||||
composer.value = 'hi changed' // out of sync → caret unresolved → append
|
||||
const calls: string[] = []
|
||||
const ctx = fakeCtx(() => 'hi', (next) => { calls.push(next); composer.value = next })
|
||||
appendToDraft(ctx, 's1', 'X')
|
||||
expect(calls).toEqual(['hi X'])
|
||||
await tick()
|
||||
expect(composer.selectionStart).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps stacked inserts at the running caret (A|B + C + D → ACD|B)', async () => {
|
||||
const composer = mountComposer('AB', 1, 1)
|
||||
let draft = 'AB'
|
||||
const ctx = fakeCtx(() => draft, (next) => { draft = next; composer.value = next })
|
||||
appendToDraft(ctx, 's1', 'C')
|
||||
await tick()
|
||||
expect(draft).toBe('A C B')
|
||||
expect(composer.selectionStart).toBe(3) // A C| B
|
||||
appendToDraft(ctx, 's1', 'D')
|
||||
await tick()
|
||||
expect(draft).toBe('A C D B')
|
||||
expect(composer.selectionStart).toBe(5) // A C D| B
|
||||
})
|
||||
|
||||
it('returns false and logs when the conversation service is unavailable', () => {
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const ctx = fakeCtx(() => '', (): void => undefined, false)
|
||||
expect(appendToDraft(ctx, 's1', 'X')).toBe(false)
|
||||
expect(consoleWarn).toHaveBeenCalledTimes(1)
|
||||
consoleWarn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* useSelectionPopup spec — the "add to conversation" popup's global dismissal
|
||||
* contract (upstream issue #425 ①). The portaled fixed button must hide on an
|
||||
* outside mousedown, on Escape, when the document goes hidden, when the
|
||||
* window loses focus, and as soon as the editor surface leaves the viewport
|
||||
* (the geometry signal behind tab switches and panel collapse) — yet it must
|
||||
* survive a mousedown ON the button so the click can still commit the
|
||||
* selection.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createElement, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { useSelectionPopup } from '../src/client/selection-popup.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
/** jsdom ships no IntersectionObserver; a controllable stand-in. */
|
||||
class FakeIntersectionObserver implements IntersectionObserver {
|
||||
static last: FakeIntersectionObserver | null = null
|
||||
readonly root: Element | Document | null = null
|
||||
readonly rootMargin = '0px'
|
||||
readonly thresholds: readonly number[] = [0]
|
||||
readonly targets = new Set<Element>()
|
||||
constructor(readonly callback: IntersectionObserverCallback) {
|
||||
FakeIntersectionObserver.last = this
|
||||
}
|
||||
observe(target: Element): void { this.targets.add(target) }
|
||||
unobserve(target: Element): void { this.targets.delete(target) }
|
||||
disconnect(): void { this.targets.clear() }
|
||||
takeRecords(): IntersectionObserverEntry[] { return [] }
|
||||
/** Test hook: report that every observed target is (or is not) intersecting. */
|
||||
fire(intersecting: boolean): void {
|
||||
this.callback([{ isIntersecting: intersecting } as IntersectionObserverEntry], this)
|
||||
}
|
||||
}
|
||||
|
||||
interface HarnessCtl {
|
||||
show(insert: string): void
|
||||
button(): HTMLButtonElement | null
|
||||
}
|
||||
|
||||
let ctl: HarnessCtl
|
||||
|
||||
/** The popup consumer's rendering shape: the hook + a portaled button. */
|
||||
function Harness(props: { onCommit: (insert: string) => void }) {
|
||||
const surfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const surface = useSelectionPopup({
|
||||
onCommit: props.onCommit,
|
||||
getSurface: () => surfaceRef.current,
|
||||
})
|
||||
ctl = {
|
||||
show: (insert: string) => act(() => { surface.show(insert, 100, 100) }),
|
||||
button: () => document.querySelector<HTMLButtonElement>('[data-popup]'),
|
||||
}
|
||||
return createElement('div', null,
|
||||
createElement('div', { ref: surfaceRef, 'data-surface': true }),
|
||||
surface.popup === null ? null : createPortal(
|
||||
createElement('button', {
|
||||
type: 'button',
|
||||
ref: surface.buttonRef,
|
||||
'data-popup': true,
|
||||
onClick: surface.commit,
|
||||
}, surface.popup.insert),
|
||||
document.body,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: ReturnType<typeof createRoot>
|
||||
let commitCalls: string[]
|
||||
|
||||
function mount(): void {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
commitCalls = []
|
||||
act(() => {
|
||||
root.render(createElement(Harness, { onCommit: (insert: string) => { commitCalls.push(insert) } }))
|
||||
})
|
||||
}
|
||||
|
||||
function unmount(): void {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
|
||||
const RealIntersectionObserver = globalThis.IntersectionObserver
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IntersectionObserver: unknown }).IntersectionObserver = FakeIntersectionObserver
|
||||
FakeIntersectionObserver.last = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as { IntersectionObserver: unknown }).IntersectionObserver = RealIntersectionObserver
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('useSelectionPopup', () => {
|
||||
it('shows the popup button with the payload', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const button = ctl.button()
|
||||
expect(button).not.toBeNull()
|
||||
expect(button?.textContent).toBe('PAYLOAD')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('commits the payload on click, then hides', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const button = ctl.button()!
|
||||
act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(commitCalls).toEqual(['PAYLOAD'])
|
||||
expect(ctl.button()).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides on a mousedown outside the button', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const elsewhere = document.createElement('div')
|
||||
document.body.append(elsewhere)
|
||||
act(() => { elsewhere.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
expect(ctl.button()).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('survives a mousedown ON the button so the click can commit', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const button = ctl.button()!
|
||||
act(() => { button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
expect(ctl.button()).not.toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides on Escape', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
expect(ctl.button()).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides when the document goes hidden (browser tab switched away)', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const prototypeDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden')
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true })
|
||||
try {
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(ctl.button()).toBeNull()
|
||||
} finally {
|
||||
if (prototypeDescriptor !== undefined) Object.defineProperty(document, 'hidden', prototypeDescriptor)
|
||||
else delete (document as { hidden?: boolean }).hidden
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides when the window loses focus', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
act(() => { window.dispatchEvent(new Event('blur')) })
|
||||
expect(ctl.button()).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('watches the editor surface: off-viewport (tab switch / collapse) hides, on-viewport keeps', () => {
|
||||
mount()
|
||||
ctl.show('PAYLOAD')
|
||||
const observer = FakeIntersectionObserver.last
|
||||
expect(observer).not.toBeNull()
|
||||
expect(observer!.targets.size).toBe(1)
|
||||
// The observed target is the hook's surface element.
|
||||
expect([...observer!.targets][0]?.getAttribute('data-surface')).toBe('true')
|
||||
// Surfaced on screen (or display:none removed): the popup stays.
|
||||
act(() => { observer!.fire(true) })
|
||||
expect(ctl.button()).not.toBeNull()
|
||||
// Hidden / translated off-screen: the popup must not survive it.
|
||||
act(() => { observer!.fire(false) })
|
||||
expect(ctl.button()).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user