✨ hover highlighted sections for feedback
This commit is contained in:
@@ -36,3 +36,85 @@
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Annotation hover tooltip ─────────────────────────────────── */
|
||||
|
||||
/* Strip CodeMirror's default tooltip chrome */
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.annotation-tooltip {
|
||||
background: var(--message-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
max-width: 320px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.annotation-tooltip-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.annotation-tooltip-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.annotation-tooltip-body {
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.annotation-tooltip-body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.annotation-tooltip-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.annotation-tooltip-body strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.annotation-tooltip-body em {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.annotation-tooltip-body code {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.annotation-tooltip-body blockquote {
|
||||
border-left: 2px solid var(--accent);
|
||||
margin: 4px 0;
|
||||
padding: 1px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.annotation-tooltip-body ul,
|
||||
.annotation-tooltip-body ol {
|
||||
margin: 2px 0 4px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.annotation-tooltip-body li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.annotation-tooltip-loading {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view'
|
||||
import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from '@codemirror/view'
|
||||
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import './Editor.css'
|
||||
@@ -12,6 +13,102 @@ import './Editor.css'
|
||||
// StateEffect to push new annotations into the editor
|
||||
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
||||
|
||||
// StateField stores the raw annotation array for hover lookup
|
||||
const rawAnnotationsField = StateField.define<TextAnnotation[]>({
|
||||
create: () => [],
|
||||
update(annotations, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setAnnotationsEffect)) return effect.value
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
})
|
||||
|
||||
// Per-session cache: annotation id → lazily generated analysis text
|
||||
const tooltipAnalysisCache = new Map<string, string>()
|
||||
|
||||
// Hover tooltip — lazily streams a specific AI analysis for the hovered passage
|
||||
const annotationHoverTooltip = hoverTooltip(
|
||||
(view, pos) => {
|
||||
const annotations = view.state.field(rawAnnotationsField)
|
||||
const ann = annotations.find(a => pos >= a.from && pos <= a.to)
|
||||
if (!ann) return null
|
||||
|
||||
return {
|
||||
pos: ann.from,
|
||||
end: ann.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'annotation-tooltip'
|
||||
|
||||
const label = document.createElement('span')
|
||||
label.className = 'annotation-tooltip-label'
|
||||
label.textContent = ann.type.replace(/_/g, ' ')
|
||||
dom.appendChild(label)
|
||||
|
||||
const divider = document.createElement('div')
|
||||
divider.className = 'annotation-tooltip-divider'
|
||||
dom.appendChild(divider)
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.className = 'annotation-tooltip-body'
|
||||
dom.appendChild(body)
|
||||
|
||||
let destroyed = false
|
||||
|
||||
function showText(text: string, streaming = false): void {
|
||||
body.classList.remove('annotation-tooltip-loading')
|
||||
const raw = marked.parse(streaming ? text + ' ▋' : text) as string
|
||||
body.innerHTML = DOMPurify.sanitize(raw)
|
||||
}
|
||||
|
||||
const cached = tooltipAnalysisCache.get(ann.id)
|
||||
if (cached) {
|
||||
showText(cached)
|
||||
} else {
|
||||
body.classList.add('annotation-tooltip-loading')
|
||||
body.innerHTML = 'Analysing…'
|
||||
|
||||
const { activeFilePath, activeFileContent } = useEditorStore.getState()
|
||||
const typeName = ann.type.replace(/_/g, ' ')
|
||||
let accumulated = ''
|
||||
|
||||
const api = (window as unknown as { api?: { streamAIMessage: (p: unknown, cb: (c: string) => void) => Promise<void> } }).api
|
||||
if (api?.streamAIMessage) {
|
||||
api.streamAIMessage(
|
||||
{
|
||||
mode: 'chat',
|
||||
documentContent: activeFileContent,
|
||||
documentPath: activeFilePath ?? '',
|
||||
conversationHistory: [],
|
||||
userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain specifically what the issue is in this exact passage, then give a direct rewrite of just this passage. Be concise and specific—no generic advice.`
|
||||
},
|
||||
(chunk: string) => {
|
||||
if (destroyed) return
|
||||
accumulated += chunk
|
||||
showText(accumulated, true)
|
||||
}
|
||||
).then(() => {
|
||||
if (destroyed) return
|
||||
const result = accumulated || ann.message
|
||||
tooltipAnalysisCache.set(ann.id, result)
|
||||
showText(result)
|
||||
}).catch(() => {
|
||||
if (!destroyed) showText(ann.message)
|
||||
})
|
||||
} else {
|
||||
showText(ann.message)
|
||||
}
|
||||
}
|
||||
|
||||
return { dom, destroy() { destroyed = true } }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ hoverTime: 500 }
|
||||
)
|
||||
|
||||
// StateField tracks the decoration set derived from annotations
|
||||
const annotationField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
@@ -31,7 +128,7 @@ const annotationField = StateField.define<DecorationSet>({
|
||||
to,
|
||||
Decoration.mark({
|
||||
class: `annotation annotation-${ann.type}`,
|
||||
attributes: { 'data-id': ann.id, title: ann.message }
|
||||
attributes: { 'data-id': ann.id }
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -96,6 +193,11 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
|
||||
backgroundColor: 'rgba(80, 160, 255, 0.18)',
|
||||
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.annotation-critique': {
|
||||
backgroundColor: 'rgba(160, 80, 220, 0.18)',
|
||||
borderBottom: '2px solid rgba(160, 80, 220, 0.7)',
|
||||
borderRadius: '2px'
|
||||
}
|
||||
},
|
||||
{ dark }
|
||||
@@ -119,7 +221,9 @@ export function MarkdownEditor(): JSX.Element {
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
markdown(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
rawAnnotationsField,
|
||||
annotationField,
|
||||
annotationHoverTooltip,
|
||||
themeCompartment.of(buildTheme(fontSize, theme === 'dark')),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
setAnalysisMode('passive_voice')
|
||||
}
|
||||
|
||||
const runAIAnalysis = async (mode: 'consistency' | 'style'): Promise<void> => {
|
||||
const runAIAnalysis = async (mode: 'consistency' | 'style' | 'critique'): Promise<void> => {
|
||||
if (!activeFilePath || isAILoading) return
|
||||
|
||||
setAnalysisMode(mode)
|
||||
@@ -65,7 +65,9 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
const prompt =
|
||||
mode === 'consistency'
|
||||
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
|
||||
: 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
||||
: mode === 'style'
|
||||
? 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
||||
: 'Please give me an honest critique of this chapter.'
|
||||
|
||||
addUserMessage(prompt)
|
||||
startAssistantMessage()
|
||||
@@ -144,6 +146,18 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`toolbar-btn${analysisMode === 'critique' ? ' active' : ''}`}
|
||||
onClick={() => runAIAnalysis('critique')}
|
||||
disabled={!hasFile || isAILoading}
|
||||
title="Honest overall critique of this chapter via AI"
|
||||
>
|
||||
{isAILoading && analysisMode === 'critique' ? 'Reading…' : 'Critique'}
|
||||
{analysisMode === 'critique' && otherCount > 0 && (
|
||||
<span className="toolbar-badge">{otherCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<button
|
||||
className="toolbar-btn toolbar-btn-clear"
|
||||
|
||||
Reference in New Issue
Block a user