✨ user comments
This commit is contained in:
@@ -5,10 +5,10 @@ import type { FileNode, RevisionMeta } from '../renderer/types/editor'
|
||||
const DRAFT_ROOT =
|
||||
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 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')
|
||||
|
||||
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> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
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> {
|
||||
await mkdir(HOHOFF_DIR, { recursive: true })
|
||||
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 'consistency': return 'rgba(220, 80, 80, 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 '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);
|
||||
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.
|
||||
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 {
|
||||
pos,
|
||||
above: true,
|
||||
@@ -380,6 +404,11 @@ function buildTheme(fontSize: number, dark: boolean): ReturnType<typeof EditorVi
|
||||
backgroundColor: 'rgba(30, 200, 150, 0.15)',
|
||||
borderBottom: '2px solid rgba(30, 200, 150, 0.7)',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.annotation-user_comment': {
|
||||
backgroundColor: 'rgba(240, 100, 180, 0.15)',
|
||||
borderBottom: '2px solid rgba(240, 100, 180, 0.65)',
|
||||
borderRadius: '2px'
|
||||
}
|
||||
},
|
||||
{ dark }
|
||||
@@ -391,8 +420,28 @@ export function MarkdownEditor(): JSX.Element {
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
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()
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
@@ -648,6 +697,19 @@ export function MarkdownEditor(): JSX.Element {
|
||||
store.setAnnotations([...store.annotations, annotation])
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -408,3 +408,22 @@
|
||||
color: var(--text-secondary);
|
||||
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 'critique': return 'rgba(160, 80, 220, 0.75)'
|
||||
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 {
|
||||
ann: TextAnnotation
|
||||
onRemove?: () => void
|
||||
@@ -191,6 +223,9 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
<span className="fb-archive-card-excerpt">"{ann.matchedText}"</span>
|
||||
{ann.comment && (
|
||||
<span className="fb-archive-card-comment">{ann.comment}</span>
|
||||
)}
|
||||
{ann.suggestion && (
|
||||
<span className="fb-archive-card-suggestion">→ {ann.suggestion}</span>
|
||||
)}
|
||||
@@ -267,14 +302,20 @@ export function FeedbackPanel(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="fb-list">
|
||||
{[...annotations].sort((a, b) => a.from - b.from).map(ann => (
|
||||
<FeedbackCard
|
||||
key={ann.id}
|
||||
ann={ann}
|
||||
autoAnalyse={analyseAll || ann.autoAnalyse === true}
|
||||
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
|
||||
/>
|
||||
))}
|
||||
{[...annotations].sort((a, b) => a.from - b.from).map(ann =>
|
||||
ann.type === 'user_comment'
|
||||
? <UserCommentCard
|
||||
key={ann.id}
|
||||
ann={ann}
|
||||
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id) }}
|
||||
/>
|
||||
: <FeedbackCard
|
||||
key={ann.id}
|
||||
ann={ann}
|
||||
autoAnalyse={analyseAll || ann.autoAnalyse === true}
|
||||
onDismiss={() => { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface ChatSession {
|
||||
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 {
|
||||
id: string
|
||||
@@ -43,6 +43,7 @@ export interface TextAnnotation {
|
||||
dismissed?: boolean // true when the user dismissed this annotation (archived)
|
||||
autoAnalyse?: boolean // true when created via context menu — FeedbackCard starts AI analysis immediately
|
||||
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'
|
||||
|
||||
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