diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 66b0fd7..405f008 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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(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(null) - /** Live mirror of the popup state for click-time reads (no re-render race). */ - const popupRef = useRef(null) /** The markdown preview container (selection-containment + line lookup). */ const mdRef = useRef(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( , diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts index 9047054..a565c38 100644 --- a/src/client/conversation-draft.ts +++ b/src/client/conversation-draft.ts @@ -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 `