✨ user comments
This commit is contained in:
@@ -5,10 +5,10 @@ import type { FileNode, RevisionMeta } from '../renderer/types/editor'
|
|||||||
const DRAFT_ROOT =
|
const DRAFT_ROOT =
|
||||||
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||||
|
|
||||||
const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
|
|
||||||
const SESSION_FILE = join(DRAFT_ROOT, '.session.json')
|
|
||||||
const REVISIONS_DIR = join(DRAFT_ROOT, '.revisions')
|
|
||||||
const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff')
|
const HOHOFF_DIR = join(DRAFT_ROOT, '.hohoff')
|
||||||
|
const ORDER_FILE = join(HOHOFF_DIR, 'order.json')
|
||||||
|
const SESSION_FILE = join(HOHOFF_DIR, 'session.json')
|
||||||
|
const REVISIONS_DIR = join(HOHOFF_DIR, 'revisions')
|
||||||
export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md')
|
export const STORY_BIBLE_PATH = join(HOHOFF_DIR, 'Story Bible.md')
|
||||||
|
|
||||||
const STORY_BIBLE_TEMPLATE = `# Story Bible
|
const STORY_BIBLE_TEMPLATE = `# Story Bible
|
||||||
@@ -56,6 +56,7 @@ async function readOrderFile(): Promise<Record<string, string[]>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
|
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
|
||||||
|
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||||
await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8')
|
await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export async function readSession(): Promise<Record<string, unknown>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSession(data: Record<string, unknown>): Promise<void> {
|
export async function writeSession(data: Record<string, unknown>): Promise<void> {
|
||||||
|
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||||
await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8')
|
await writeFile(SESSION_FILE, JSON.stringify(data), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ function badgeColor(type: TextAnnotation['type']): string {
|
|||||||
case 'passive_voice': return 'rgba(255, 200, 0, 0.75)'
|
case 'passive_voice': return 'rgba(255, 200, 0, 0.75)'
|
||||||
case 'consistency': return 'rgba(220, 80, 80, 0.75)'
|
case 'consistency': return 'rgba(220, 80, 80, 0.75)'
|
||||||
case 'style': return 'rgba(80, 160, 255, 0.75)'
|
case 'style': return 'rgba(80, 160, 255, 0.75)'
|
||||||
|
case 'show_tell': return 'rgba(255, 140, 30, 0.75)'
|
||||||
case 'critique': return 'rgba(160, 80, 220, 0.75)'
|
case 'critique': return 'rgba(160, 80, 220, 0.75)'
|
||||||
case 'custom': return 'rgba(30, 200, 150, 0.8)'
|
case 'custom': return 'rgba(30, 200, 150, 0.8)'
|
||||||
|
case 'user_comment': return 'rgba(240, 100, 180, 0.85)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,3 +263,97 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--bg-base);
|
color: var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Comment input modal ───────────────────────────────── */
|
||||||
|
.comment-modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal {
|
||||||
|
background: var(--message-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
width: 340px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-excerpt {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
border-left: 2px solid rgba(240, 100, 180, 0.65);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-input {
|
||||||
|
background: var(--input-bg, rgba(255, 255, 255, 0.05));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding: 7px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-input:focus {
|
||||||
|
border-color: rgba(240, 100, 180, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-cancel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-cancel:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-save {
|
||||||
|
background: rgba(240, 100, 180, 0.85);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #1a0a12;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-save:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-modal-save:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,6 +205,30 @@ const annotationHoverTooltip = hoverTooltip(
|
|||||||
// across the nested create() closure without requiring non-null assertions.
|
// across the nested create() closure without requiring non-null assertions.
|
||||||
const ann: TextAnnotation = found
|
const ann: TextAnnotation = found
|
||||||
|
|
||||||
|
// User comments: show static tooltip with the comment text — no AI streaming
|
||||||
|
if (ann.type === 'user_comment') {
|
||||||
|
return {
|
||||||
|
pos,
|
||||||
|
above: true,
|
||||||
|
create() {
|
||||||
|
const dom = document.createElement('div')
|
||||||
|
dom.className = 'annotation-tooltip'
|
||||||
|
const label = document.createElement('span')
|
||||||
|
label.className = 'annotation-tooltip-label'
|
||||||
|
label.textContent = 'Your comment'
|
||||||
|
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'
|
||||||
|
body.textContent = ann.comment ?? ann.message
|
||||||
|
dom.appendChild(body)
|
||||||
|
return { dom, destroy() {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pos,
|
pos,
|
||||||
above: true,
|
above: true,
|
||||||
@@ -380,6 +404,11 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
|
|||||||
backgroundColor: 'rgba(30, 200, 150, 0.15)',
|
backgroundColor: 'rgba(30, 200, 150, 0.15)',
|
||||||
borderBottom: '2px solid rgba(30, 200, 150, 0.7)',
|
borderBottom: '2px solid rgba(30, 200, 150, 0.7)',
|
||||||
borderRadius: '2px'
|
borderRadius: '2px'
|
||||||
|
},
|
||||||
|
'.annotation-user_comment': {
|
||||||
|
backgroundColor: 'rgba(240, 100, 180, 0.15)',
|
||||||
|
borderBottom: '2px solid rgba(240, 100, 180, 0.65)',
|
||||||
|
borderRadius: '2px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ dark }
|
{ dark }
|
||||||
@@ -391,8 +420,28 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
const viewRef = useRef<EditorView | null>(null)
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [hasSelection, setHasSelection] = useState(false)
|
const [hasSelection, setHasSelection] = useState(false)
|
||||||
|
const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null)
|
||||||
|
const [commentDraft, setCommentDraft] = useState('')
|
||||||
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore()
|
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore()
|
||||||
|
|
||||||
|
function saveComment(): void {
|
||||||
|
if (!pendingComment || !commentDraft.trim()) return
|
||||||
|
const annotation: TextAnnotation = {
|
||||||
|
id: `user-comment-${Date.now()}`,
|
||||||
|
type: 'user_comment',
|
||||||
|
from: pendingComment.from,
|
||||||
|
to: pendingComment.to,
|
||||||
|
matchedText: pendingComment.text,
|
||||||
|
message: commentDraft.trim(),
|
||||||
|
comment: commentDraft.trim(),
|
||||||
|
}
|
||||||
|
const store = useEditorStore.getState()
|
||||||
|
store.setAnnotations([...store.annotations, annotation])
|
||||||
|
store.setRightPanelTab('feedback')
|
||||||
|
setPendingComment(null)
|
||||||
|
setCommentDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize CodeMirror once
|
// Initialize CodeMirror once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
@@ -648,6 +697,19 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
store.setAnnotations([...store.annotations, annotation])
|
store.setAnnotations([...store.annotations, annotation])
|
||||||
store.setRightPanelTab('feedback')
|
store.setRightPanelTab('feedback')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add comment',
|
||||||
|
action: () => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
const { from, to } = view.state.selection.main
|
||||||
|
const text = view.state.sliceDoc(from, to)
|
||||||
|
if (!text.trim()) return
|
||||||
|
setMenuPos(null)
|
||||||
|
setPendingComment({ from, to, text })
|
||||||
|
setCommentDraft('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
] : [])
|
] : [])
|
||||||
]
|
]
|
||||||
@@ -669,6 +731,33 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
onClose={() => setMenuPos(null)}
|
onClose={() => setMenuPos(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pendingComment && (
|
||||||
|
<div className="comment-modal-overlay" onClick={() => setPendingComment(null)}>
|
||||||
|
<div className="comment-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="comment-modal-excerpt">
|
||||||
|
“{pendingComment.text.length > 80
|
||||||
|
? pendingComment.text.slice(0, 80) + '…'
|
||||||
|
: pendingComment.text}”
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="comment-modal-input"
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
value={commentDraft}
|
||||||
|
onChange={e => setCommentDraft(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveComment() }
|
||||||
|
if (e.key === 'Escape') setPendingComment(null)
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="comment-modal-actions">
|
||||||
|
<button className="comment-modal-cancel" onClick={() => setPendingComment(null)}>Cancel</button>
|
||||||
|
<button className="comment-modal-save" onClick={saveComment} disabled={!commentDraft.trim()}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,3 +408,22 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── User comment card body ──────────────────────────── */
|
||||||
|
.fb-card-user-comment-body {
|
||||||
|
padding: 4px 10px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-archive-card-comment {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function badgeColor(type: TextAnnotation['type']): string {
|
|||||||
case 'show_tell': return 'rgba(255, 140, 30, 0.75)'
|
case 'show_tell': return 'rgba(255, 140, 30, 0.75)'
|
||||||
case 'critique': return 'rgba(160, 80, 220, 0.75)'
|
case 'critique': return 'rgba(160, 80, 220, 0.75)'
|
||||||
case 'custom': return 'rgba(30, 200, 150, 0.8)'
|
case 'custom': return 'rgba(30, 200, 150, 0.8)'
|
||||||
|
case 'user_comment': return 'rgba(240, 100, 180, 0.85)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +166,37 @@ function FeedbackCard({ ann, autoAnalyse, onDismiss }: FeedbackCardProps): JSX.E
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserCommentCardProps {
|
||||||
|
ann: TextAnnotation
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCommentCard({ ann, onDismiss }: UserCommentCardProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fb-card fb-card-user_comment"
|
||||||
|
style={{ '--badge-color': 'rgba(240, 100, 180, 0.85)' } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="fb-card-header" onClick={() => scrollToAnnotation(ann)} title="Jump to passage">
|
||||||
|
<div className="fb-card-header-top">
|
||||||
|
<div className="fb-card-header-badges">
|
||||||
|
<span className="fb-card-badge">Your comment</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="fb-card-dismiss"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDismiss() }}
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="fb-card-excerpt">"{ann.matchedText}"</span>
|
||||||
|
</div>
|
||||||
|
<div className="fb-card-user-comment-body">{ann.comment}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ArchiveCardProps {
|
interface ArchiveCardProps {
|
||||||
ann: TextAnnotation
|
ann: TextAnnotation
|
||||||
onRemove?: () => void
|
onRemove?: () => void
|
||||||
@@ -191,6 +223,9 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="fb-archive-card-excerpt">"{ann.matchedText}"</span>
|
<span className="fb-archive-card-excerpt">"{ann.matchedText}"</span>
|
||||||
|
{ann.comment && (
|
||||||
|
<span className="fb-archive-card-comment">{ann.comment}</span>
|
||||||
|
)}
|
||||||
{ann.suggestion && (
|
{ann.suggestion && (
|
||||||
<span className="fb-archive-card-suggestion">→ {ann.suggestion}</span>
|
<span className="fb-archive-card-suggestion">→ {ann.suggestion}</span>
|
||||||
)}
|
)}
|
||||||
@@ -267,14 +302,20 @@ export function FeedbackPanel(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fb-list">
|
<div className="fb-list">
|
||||||
{[...annotations].sort((a, b) => a.from - b.from).map(ann => (
|
{[...annotations].sort((a, b) => a.from - b.from).map(ann =>
|
||||||
<FeedbackCard
|
ann.type === 'user_comment'
|
||||||
|
? <UserCommentCard
|
||||||
|
key={ann.id}
|
||||||
|
ann={ann}
|
||||||
|
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id) }}
|
||||||
|
/>
|
||||||
|
: <FeedbackCard
|
||||||
key={ann.id}
|
key={ann.id}
|
||||||
ann={ann}
|
ann={ann}
|
||||||
autoAnalyse={analyseAll || ann.autoAnalyse === true}
|
autoAnalyse={analyseAll || ann.autoAnalyse === true}
|
||||||
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
|
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface ChatSession {
|
|||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom'
|
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment'
|
||||||
|
|
||||||
export interface TextAnnotation {
|
export interface TextAnnotation {
|
||||||
id: string
|
id: string
|
||||||
@@ -43,6 +43,7 @@ export interface TextAnnotation {
|
|||||||
dismissed?: boolean // true when the user dismissed this annotation (archived)
|
dismissed?: boolean // true when the user dismissed this annotation (archived)
|
||||||
autoAnalyse?: boolean // true when created via context menu — FeedbackCard starts AI analysis immediately
|
autoAnalyse?: boolean // true when created via context menu — FeedbackCard starts AI analysis immediately
|
||||||
analysisCache?: { text: string; suggestion: string | null } // persisted AI analysis result
|
analysisCache?: { text: string; suggestion: string | null } // persisted AI analysis result
|
||||||
|
comment?: string // user-written note text (only set for user_comment type)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique'
|
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style' | 'show_tell' | 'critique'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user