import React from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { PreviewToggleButton } from './PreviewToggleButton'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { useDeviceInfo } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight'; import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { languageByExtension } from '@/lib/codemirror/languageByExtension'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { EditorView } from '@codemirror/view'; import { copyTextToClipboard } from '@/lib/clipboard'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog'; import { Icon } from "@/components/icon/Icon"; import { useMessageTTS } from '@/hooks/useMessageTTS'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { useI18n } from '@/lib/i18n'; type PlanViewProps = { targetPath?: string | null; /** Saved project plan to open. Project plans are server-owned and addressed by id; they never carry a client-visible filesystem path. */ projectPlanId?: string | null; /** Called after a send action routes the user to the chat — hosts that show PlanView in an overlay (mobile fullscreen surface) close it here. */ onNavigatedToChat?: () => void; }; type PlanSendAction = 'improve' | 'implement'; type PlanSendTarget = 'session' | 'worktree'; type PendingPlanSend = { action: PlanSendAction; target: PlanSendTarget; }; const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); }; const joinPath = (base: string, segment: string): string => { const normalizedBase = normalize(base); const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); if (!normalizedBase || normalizedBase === '/') { return `/${cleanSegment}`; } return `${normalizedBase}/${cleanSegment}`; }; const buildRepoPlanPath = (directory: string, created: number, slug: string): string => { return joinPath(joinPath(joinPath(directory, '.opencode'), 'plans'), `${created}-${slug}.md`); }; const buildHomePlanPath = (created: number, slug: string): string => { return `~/.opencode/plans/${created}-${slug}.md`; }; const resolveTilde = (path: string, homeDir: string | null): string => { const trimmed = path.trim(); if (!trimmed.startsWith('~')) return trimmed; if (trimmed === '~') return homeDir || trimmed; if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed; } return trimmed; }; const toDisplayPath = (resolvedPath: string, options: { currentDirectory: string; homeDirectory: string }): string => { const current = normalize(options.currentDirectory); const home = normalize(options.homeDirectory); const normalized = normalize(resolvedPath); if (current && normalized.startsWith(current + '/')) { return normalized.slice(current.length + 1); } if (home && normalized === home) { return '~'; } if (home && normalized.startsWith(home + '/')) { return `~${normalized.slice(home.length)}`; } return normalized; }; const resolveProjectRefForDirectory = ( directory: string, projects: Array<{ id: string; path: string }>, activeProjectId: string | null, ): { id: string; path: string } | null => { const normalized = normalize(directory.trim()); if (!normalized) { return null; } const activeProject = activeProjectId ? projects.find((project) => project.id === activeProjectId) ?? null : null; if (activeProject?.path) { const activePath = normalize(activeProject.path); if (normalized === activePath || normalized.startsWith(`${activePath}/`)) { return { id: activeProject.id, path: activeProject.path }; } } const match = projects .filter((project) => { const projectPath = normalize(project.path); return normalized === projectPath || normalized.startsWith(`${projectPath}/`); }) .sort((left, right) => normalize(right.path).length - normalize(left.path).length)[0]; return match ? { id: match.id, path: match.path } : null; }; type SelectedLineRange = { start: number; end: number; }; export const PlanView: React.FC = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const createSession = useSessionUIStore((state) => state.createSession); const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession); const sendMessage = useSessionUIStore((state) => state.sendMessage); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const gitDirectories = useGitStore((state) => state.directories); const effectiveDirectory = useEffectiveDirectory() ?? ''; const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const runtimeApis = useRuntimeAPIs(); const { isMobile } = useDeviceInfo(); const { currentTheme } = useThemeSystem(); const session = React.useMemo(() => { if (!currentSessionId) return null; return sessions.find((s) => s.id === currentSessionId) ?? null; }, [currentSessionId, sessions]); const sessionDirectory = React.useMemo(() => { const raw = typeof session?.directory === 'string' ? session.directory : ''; return normalize(raw || ''); }, [session?.directory]); const projectDirectory = React.useMemo( () => normalize(effectiveDirectory || sessionDirectory), [effectiveDirectory, sessionDirectory], ); const currentProjectRef = React.useMemo( () => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId), [activeProjectId, projectDirectory, projects], ); const canCreateWorktree = React.useMemo( () => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false), [currentProjectRef, gitDirectories], ); const [pendingPlanSend, setPendingPlanSend] = React.useState(null); const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false); const [resolvedPath, setResolvedPath] = React.useState(null); // Set once a saved project plan has actually loaded. Kept separate from // `resolvedPath` so nothing downstream can mistake a project plan for a file // the user could open, edit, or be shown a path for. const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState(null); const savePlan = useProjectContextStore((state) => state.savePlan); const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId); const displayPath = React.useMemo(() => { if (!resolvedPath || !sessionDirectory || !homeDirectory) { return resolvedPath; } return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory }); }, [resolvedPath, sessionDirectory, homeDirectory]); const [content, setContent] = React.useState(''); const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const [saveError, setSaveError] = React.useState(null); const planFileLabel = React.useMemo(() => { return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName'); }, [displayPath, t]); const parsedTitle = React.useMemo(() => { if (!content.trim()) { return t('planView.title.default'); } return parsePlanMarkdown(content, t('planView.title.default')).title; }, [content, t]); const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]); const [loading, setLoading] = React.useState(false); const [copiedContent, setCopiedContent] = React.useState(false); const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); const copiedContentTimeoutRef = React.useRef(null); const [lineSelection, setLineSelection] = React.useState(null); const editorViewRef = React.useRef(null); const editorWrapperRef = React.useRef(null); const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode'; React.useEffect(() => { try { const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); if (!stored) return; const parsed = JSON.parse(stored) as unknown; if (parsed === 'preview' || parsed === 'edit') { setMdViewMode(parsed); } } catch { // ignore } }, []); const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { setMdViewMode(mode); try { localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode)); } catch { // ignore } }, []); const isSelectingRef = React.useRef(false); const selectionStartRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); React.useEffect(() => { const handleGlobalMouseUp = () => { isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); }; document.addEventListener('mouseup', handleGlobalMouseUp); return () => document.removeEventListener('mouseup', handleGlobalMouseUp); }, []); const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => { const lines = text.split('\n'); const startLine = Math.max(1, range.start); const endLine = Math.min(lines.length, range.end); if (startLine > endLine) return ''; return lines.slice(startLine - 1, endLine).join('\n'); }, []); const commentController = useInlineCommentController({ source: 'plan', fileLabel: planFileLabel, language: resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown', getCodeForRange: (range) => extractSelectedCode(content, normalizeLineRange(range)), toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }), fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), }); const { drafts: planFileDrafts, commentText, setCommentText, editingDraftId, setSelection: setCommentSelection, saveComment, cancel, reset, startEdit, deleteDraft, } = commentController; React.useEffect(() => { setLineSelection(null); reset(); }, [content, reset]); React.useEffect(() => { setCommentSelection(lineSelection); }, [lineSelection, setCommentSelection]); const handleCancelComment = React.useCallback(() => { setLineSelection(null); cancel(); }, [cancel]); const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => { if (rangeOverride) { setLineSelection(rangeOverride); } saveComment(textToSave, rangeOverride ?? lineSelection ?? undefined); setLineSelection(null); }, [lineSelection, saveComment]); React.useEffect(() => { if (!lineSelection) return; if (isMobile && !editingDraftId) { // Input handles mobile scroll/focus behavior. } const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if ( target.closest('[data-comment-card="true"]') || target.closest('[data-comment-input="true"]') || target.closest('.oc-block-widget') ) { return; } if (target.closest('.cm-gutterElement')) return; if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; if (!commentText.trim()) { setLineSelection(null); cancel(); } }; const timeoutId = window.setTimeout(() => { document.addEventListener('click', handleClickOutside); }, 100); return () => { window.clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; }, [cancel, commentText, editingDraftId, isMobile, lineSelection]); const editorFontSize = useUIStore((state) => state.editorFontSize); const editorExtensions = React.useMemo(() => { // Shiki token colors only for code files; markdown keeps the lezer // highlighter (markdown-aware bold headings etc., and no Shiki view to match). const shikiLanguage = resolvedPath ? getLanguageFromExtension(resolvedPath) : null; const useShiki = Boolean(shikiLanguage) && shikiLanguage !== 'markdown'; const extensions = [createFlexokiCodeMirrorTheme(currentTheme, useShiki ? { syntaxColors: false, fontSize: editorFontSize } : { fontSize: editorFontSize })]; const language = languageByExtension(resolvedPath || 'plan.md'); if (language) { extensions.push(language); } if (useShiki && shikiLanguage) { extensions.push(shikiHighlightExtension({ language: shikiLanguage, themeName: currentTheme.metadata.id, theme: getResolvedShikiTheme(currentTheme), })); } extensions.push(EditorView.lineWrapping); return extensions; }, [currentTheme, resolvedPath, editorFontSize]); React.useEffect(() => { // Saved project plans opened via context panel should work even when session plan mode is off. if (!planModeEnabled && !targetPath && !projectPlanId) { setResolvedPath(null); setLoadedProjectPlanId(null); setContent(''); setLoading(false); return; } let cancelled = false; const readText = async (path: string): Promise => { if (runtimeApis.files?.readFile) { const result = await runtimeApis.files.readFile(path); return result?.content ?? ''; } const runtimeFiles = getRegisteredRuntimeAPIs()?.files; if (runtimeFiles?.readFile) { const result = await runtimeFiles.readFile(path, { optional: true }); return result?.content ?? ''; } const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', }); if (!response.ok) { throw new Error(`Failed to read plan file (${response.status})`); } return response.text(); }; const run = async () => { setResolvedPath(null); setLoadedProjectPlanId(null); setContent(''); setSaveError(null); if (projectPlanId) { if (!currentProjectRef) { return; } setLoading(true); try { const plan = await fetchProjectPlan(currentProjectRef, projectPlanId); if (cancelled) return; if (!plan) { // The plan or its markdown is gone. Leave the view empty and // unsaveable rather than presenting an editor that would recreate // a document the user deleted. setSaveError(t('planView.error.loadFailed')); return; } setContent(plan.raw); setLoadedProjectPlanId(projectPlanId); } catch (error) { if (cancelled) return; setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed')); } finally { if (!cancelled) setLoading(false); } return; } if (targetPath) { setLoading(true); try { const text = await readText(targetPath); if (cancelled) return; setResolvedPath(targetPath); setContent(text); } catch { if (cancelled) return; setResolvedPath(null); setContent(''); } finally { if (!cancelled) setLoading(false); } return; } if (!session?.slug || !session?.time?.created || !sessionDirectory) { setResolvedPath(null); setContent(''); return; } setLoading(true); try { const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug); const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null); let resolved: string | null = null; let text: string | null = null; try { text = await readText(repoPath); resolved = repoPath; } catch { // ignore } if (!resolved) { try { text = await readText(homePath); resolved = homePath; } catch { // ignore } } if (cancelled) return; if (!resolved || text === null) { setResolvedPath(null); setContent(''); return; } setResolvedPath(resolved); setContent(text); } catch { if (cancelled) return; setResolvedPath(null); setContent(''); } finally { if (!cancelled) setLoading(false); } }; void run(); return () => { cancelled = true; }; }, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]); React.useEffect(() => { if (!resolvedPath && !loadedProjectPlanId) { return; } const controller = window.setTimeout(async () => { setSaveError(null); try { if (loadedProjectPlanId) { if (!currentProjectRef) { throw new Error(t('planView.error.writeFailed')); } const saved = await savePlan(currentProjectRef, loadedProjectPlanId, content); if (!saved) { throw new Error(t('planView.error.writeFailed')); } return; } if (!resolvedPath) { return; } if (runtimeApis.files?.writeFile) { const result = await runtimeApis.files.writeFile(resolvedPath, content); if (!result?.success) { throw new Error(t('planView.error.writeFailed')); } } else { const response = await runtimeFetch('/api/fs/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: resolvedPath, content }), }); if (!response.ok) { throw new Error(t('planView.error.writePlanFileFailed', { status: response.status })); } } } catch (error) { setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed')); } }, 350); return () => { window.clearTimeout(controller); }; }, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]); React.useEffect(() => { return () => { if (copiedContentTimeoutRef.current !== null) { window.clearTimeout(copiedContentTimeoutRef.current); } }; }, []); const routeToChat = React.useCallback(() => { setSessionSwitcherOpen(false); onNavigatedToChat?.(); }, [onNavigatedToChat, setSessionSwitcherOpen]); const handleConfirmPlanSend = React.useCallback( async (execution: TodoSendExecution) => { if (!currentProjectRef || !pendingPlanSend) { return; } const visiblePrompt = await renderMagicPrompt( pendingPlanSend.action === 'improve' ? 'plan.improve.visible' : 'plan.implement.visible', { plan_title: sendPromptTitle, }, ); const instructionsText = await renderMagicPrompt( pendingPlanSend.action === 'improve' ? 'plan.improve.instructions' : 'plan.implement.instructions', { plan_title: sendPromptTitle, plan_path: resolvedPath ?? '', }, ); const syntheticParts = [{ synthetic: true as const, text: instructionsText }]; setIsPlanSendSubmitting(true); try { routeToChat(); let sessionId: string | null = null; let directoryHint: string | null = currentProjectRef.path; if (pendingPlanSend.target === 'worktree') { if (!canCreateWorktree) { return; } const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName()); if (!created?.id) { return; } sessionId = created.id; directoryHint = created.path; } else { const sessionResult = await createSession(undefined, currentProjectRef.path, null); if (!sessionResult?.id) { return; } sessionId = sessionResult.id; directoryHint = sessionResult.directory ?? currentProjectRef.path; initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []); } if (!sessionId) { return; } const selectionState = useSelectionStore.getState(); selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID); if (execution.agent.trim()) { selectionState.saveSessionAgentSelection(sessionId, execution.agent); selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID); selectionState.saveAgentModelVariantForSession( sessionId, execution.agent, execution.providerID, execution.modelID, execution.variant || undefined, ); } setCurrentSession(sessionId, directoryHint); // "Run as goal" rides the same arm mechanism as the composer target // button; set explicitly either way so a stray armed flag cannot // leak into a non-goal plan send. The objective override carries the // plan substance — "Implement this plan: X" alone would give the // progress audit nothing to judge against. Plans that exceed the // objective limit are distilled into completion criteria by the // small model (the working agent always reads the full plan from // its file); on distillation failure a head+tail excerpt keeps the // intent (top) and acceptance criteria (bottom), sacrificing the // implementation middle the agent reads from the file anyway. // Oversized objectives (huge plans) are distilled into audit // criteria inside setSessionGoal — the shared path for every goal // source. Here we only compose header + full content. const goalObjective = execution.runAsGoal === true ? [ `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, 'Re-read that file for full details — it is the source of truth.', '', content, ].join('\n') : null; useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective); await sendMessage( visiblePrompt, execution.providerID, execution.modelID, execution.agent.trim() || undefined, undefined, undefined, syntheticParts, execution.variant || undefined, ); setPendingPlanSend(null); } finally { setIsPlanSendSubmitting(false); } }, [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession] ); const blockWidgets = React.useMemo(() => { return buildCodeMirrorCommentWidgets({ drafts: planFileDrafts, editingDraftId, commentText, onTextChange: setCommentText, selection: lineSelection, isDragging, fileLabel: planFileLabel, newWidgetId: 'plan-new-comment-input', mapDraftToRange: (draft) => ({ start: draft.startLine, end: draft.endLine }), onSave: handleSaveComment, onCancel: handleCancelComment, onEdit: (draft) => { startEdit(draft); setLineSelection({ start: draft.startLine, end: draft.endLine }); }, onDelete: deleteDraft, }); }, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, setCommentText, startEdit]); return (
{parsedTitle}
{saveError ? (
{t('planView.error.saveFailed')}
) : null}
{hasDocument ? (
{t('planView.actions.improve')} setPendingPlanSend({ action: 'improve', target: 'session' })}> {t('planView.actions.sendToNewSession')} setPendingPlanSend({ action: 'improve', target: 'worktree' })} disabled={!canCreateWorktree} > {t('planView.actions.sendToNewWorktreeSession')} {t('planView.actions.implement')} setPendingPlanSend({ action: 'implement', target: 'session' })}> {t('planView.actions.sendToNewSession')} setPendingPlanSend({ action: 'implement', target: 'worktree' })} disabled={!canCreateWorktree} > {t('planView.actions.sendToNewWorktreeSession')} saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')} /> {mdViewMode === 'preview' && showMessageTTSButtons && ( {isTTSPlaying ? t('planView.tts.stopSpeaking') : t('planView.tts.readAloud')} )}
) : null}
{ if (!open && !isPlanSendSubmitting) { setPendingPlanSend(null); } }} target={pendingPlanSend?.target ?? 'session'} projectDirectory={currentProjectRef?.path ?? null} submitting={isPlanSendSubmitting} allowRunAsGoal onConfirm={handleConfirmPlanSend} />
{loading ? (
{t('planView.state.loading')}
) : (
{mdViewMode === 'preview' ? (
{t('planView.error.previewUnavailable')}
{t('planView.error.switchToEditMode')}
} >
) : (
{ editorViewRef.current = view; }} onViewDestroy={() => { editorViewRef.current = null; }} blockWidgets={blockWidgets} highlightLines={lineSelection ? { start: Math.min(lineSelection.start, lineSelection.end), end: Math.max(lineSelection.start, lineSelection.end), } : undefined} lineNumbersConfig={{ domEventHandlers: { mousedown: (view, line, event) => { if (!(event instanceof MouseEvent)) return false; if (event.button !== 0) return false; event.preventDefault(); const lineNumber = view.state.doc.lineAt(line.from).number; if ( lineSelection && !event.shiftKey && Math.min(lineSelection.start, lineSelection.end) === lineNumber && Math.max(lineSelection.start, lineSelection.end) === lineNumber ) { setLineSelection(null); cancel(); isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); return true; } if (isMobile && lineSelection && !event.shiftKey) { const start = Math.min(lineSelection.start, lineSelection.end, lineNumber); const end = Math.max(lineSelection.start, lineSelection.end, lineNumber); setLineSelection({ start, end }); isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); return true; } isSelectingRef.current = true; selectionStartRef.current = lineNumber; setIsDragging(true); if (lineSelection && event.shiftKey) { const start = Math.min(lineSelection.start, lineNumber); const end = Math.max(lineSelection.end, lineNumber); setLineSelection({ start, end }); } else { setLineSelection({ start: lineNumber, end: lineNumber }); } return true; }, mouseover: (view, line, event) => { if (!(event instanceof MouseEvent)) return false; if (event.buttons !== 1) return false; if (!isSelectingRef.current || selectionStartRef.current === null) return false; const lineNumber = view.state.doc.lineAt(line.from).number; const start = Math.min(selectionStartRef.current, lineNumber); const end = Math.max(selectionStartRef.current, lineNumber); setLineSelection({ start, end }); setIsDragging(true); return false; }, mouseup: () => { isSelectingRef.current = false; selectionStartRef.current = null; setIsDragging(false); return false; }, }, }} />
)}
)}
); };