Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Writing assistant v2 #487

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,850 changes: 2,514 additions & 336 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,16 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@remirror/pm": "^3.0.0",
"@remirror/react": "^3.0.1",
"@sentry/electron": "^5.3.0",
"@sentry/vite-plugin": "^2.22.6",
"@tailwindcss/typography": "^0.5.10",
"@tiptap/core": "^2.5.0",
"@tiptap/extension-bubble-menu": "^2.4.0",
"@tiptap/extension-character-count": "^2.7.2",
"@tiptap/extension-document": "^2.5.0",
"@tiptap/extension-highlight": "^2.9.1",
"@tiptap/extension-link": "^2.2.4",
"@tiptap/extension-paragraph": "^2.5.0",
"@tiptap/extension-table": "^2.4.0",
Expand Down Expand Up @@ -121,6 +124,7 @@
"react-type-animation": "^3.2.0",
"react-window": "^1.8.10",
"rehype-raw": "^7.0.0",
"remirror": "^3.0.1",
"remove-markdown": "^0.5.0",
"slugify": "^1.6.6",
"tailwind-merge": "^2.5.2",
Expand Down
105 changes: 105 additions & 0 deletions src/components/Editor/AIEdit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState } from 'react'
import { streamText } from 'ai'
import { ArrowUp } from 'lucide-react'
import { Button } from '../ui/button'
import resolveLLMClient from '@/lib/llm/client'
import MarkdownRenderer from '../Common/MarkdownRenderer'

interface AiEditMenuProps {
selectedText: string
onEdit: (newText: string) => void
}

const AiEditMenu = ({ selectedText, onEdit }: AiEditMenuProps) => {
const [response, setResponse] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [instruction, setInstruction] = useState('')

const handleEdit = async () => {
try {
setIsLoading(true)
setResponse('')
const defaultLLMName = await window.llm.getDefaultLLMName()
const llmClient = await resolveLLMClient(defaultLLMName)
const { textStream } = await streamText({
model: llmClient,
messages: [
{
role: 'system',
content:
"Edit the user provided text following the user's instruction. You must respond in the same language as the user's instruction and only return the edited text.",
},
{
role: 'user',
content: `Instruction: ${instruction}\nText: ${selectedText}`,
},
],
})

// eslint-disable-next-line no-restricted-syntax
for await (const textPart of textStream) {
setResponse((prev) => prev + textPart)
}
} finally {
setIsLoading(false)
}
}

return (
<div className="flex flex-col gap-2">
{(response || isLoading) && (
<div className="relative rounded-md border border-border bg-background/95 p-3 text-sm text-foreground shadow-lg backdrop-blur">
{isLoading && !response && <div className="animate-pulse text-muted-foreground">Generating response...</div>}
{response && (
<>
<div className="prose prose-invert max-h-[400px] max-w-none overflow-y-auto">
<MarkdownRenderer content={response} />
</div>
<div className="mt-2 flex justify-end">
<Button
onClick={() => onEdit(response)}
size="sm"
variant="default"
className="bg-purple-500 hover:bg-purple-600"
>
Apply Edit
</Button>
</div>
</>
)}
</div>
)}

<div className="flex items-center gap-2 bg-background">
<textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="Enter your editing instruction..."
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter' && !e.shiftKey && !isLoading) {
e.preventDefault()
handleEdit()
}
}}
rows={1}
className="z-50 flex w-full flex-col overflow-hidden rounded border-2 border-solid border-border bg-background p-2 text-white outline-none focus-within:ring-1 focus-within:ring-ring"
/>
<Button
onClick={handleEdit}
size="icon"
variant="ghost"
className="text-purple-500 hover:bg-purple-500/10"
disabled={isLoading}
>
<ArrowUp className="size-5" />
</Button>
</div>
</div>
)
}

export default AiEditMenu
36 changes: 36 additions & 0 deletions src/components/Editor/DocumentStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { useEffect, useState } from 'react'
import { Editor } from '@tiptap/react'

interface DocumentStatsProps {
editor: Editor | null
}

const DocumentStats: React.FC<DocumentStatsProps> = ({ editor }) => {
const [show, setShow] = useState(false)

useEffect(() => {
const initDocumentStats = async () => {
const showStats = await window.electronStore.getDocumentStats()
setShow(showStats)
}

initDocumentStats()

const handleDocStatsChange = (event: Electron.IpcRendererEvent, value: boolean) => {
setShow(value)
}

window.ipcRenderer.on('show-doc-stats-changed', handleDocStatsChange)
}, [])
Comment on lines +11 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: missing cleanup function to remove IPC event listener on unmount


if (!editor || !show) return null

return (
<div className="absolute bottom-2 right-2 flex gap-4 text-sm text-gray-500">
<div>Characters: {editor.storage.characterCount.characters()}</div>
<div>Words: {editor.storage.characterCount.words()}</div>
</div>
)
}

export default DocumentStats
144 changes: 104 additions & 40 deletions src/components/Editor/EditorManager.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
/* eslint-disable react/button-has-type */
import React, { useEffect, useState } from 'react'
import { EditorContent } from '@tiptap/react'
import InEditorBacklinkSuggestionsDisplay from './BacklinkSuggestionsDisplay'
import { EditorContent, BubbleMenu } from '@tiptap/react'
import { getHTMLFromFragment, Range } from '@tiptap/core'
import TurndownService from 'turndown'
import EditorContextMenu from './EditorContextMenu'
import SearchBar from './Search/SearchBar'
import { useFileContext } from '@/contexts/FileContext'
import { useContentContext } from '@/contexts/ContentContext'
import DocumentStats from './DocumentStats'
import AiEditMenu from './AIEdit'

const EditorManager: React.FC = () => {
const [showSearchBar, setShowSearchBar] = useState(false)
const [contextMenuVisible, setContextMenuVisible] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [editorFlex, setEditorFlex] = useState(true)

const { editor, suggestionsState, vaultFilesFlattened } = useFileContext()
const [showDocumentStats, setShowDocumentStats] = useState(false)
const { openContent } = useContentContext()
const [showAIPopup, setShowAIPopup] = useState(false)
const { editor } = useFileContext()
const turndownService = new TurndownService()
const [selectedRange, setSelectedRange] = useState<Range | null>(null)

const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
Expand All @@ -29,15 +32,6 @@
if (contextMenuVisible) setContextMenuVisible(false)
}

const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const { target } = event
if (target instanceof HTMLElement && target.getAttribute('data-backlink') === 'true') {
event.preventDefault()
const backlinkPath = target.textContent
if (backlinkPath) openContent(backlinkPath)
}
}

useEffect(() => {
const initEditorContentCenter = async () => {
const isCenter = await window.electronStore.getEditorFlexCenter()
Expand All @@ -53,25 +47,109 @@
}, [])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: missing cleanup for ipcRenderer event listener


useEffect(() => {
const initDocumentStats = async () => {
const showStats = await window.electronStore.getDocumentStats()
setShowDocumentStats(showStats)
if (!editor) return

if (showAIPopup && selectedRange) {
editor.chain().focus().setMark('highlight').run()
}
}, [showAIPopup, selectedRange, editor])

initDocumentStats()
useEffect(() => {
if (!editor) return

const handleDocStatsChange = (event: Electron.IpcRendererEvent, value: boolean) => {
setShowDocumentStats(value)
if (!showAIPopup) {
editor?.commands.clearFormatting()
}
}, [showAIPopup, editor])

window.ipcRenderer.on('show-doc-stats-changed', handleDocStatsChange)
}, [])
useEffect(() => {
if (showAIPopup) {
setTimeout(() => {
const textarea = document.querySelector('.ai-edit-menu textarea')
if (textarea instanceof HTMLTextAreaElement) {
textarea.focus()
}
}, 50)
}
}, [showAIPopup])

return (
<div
className="relative size-full cursor-text overflow-hidden bg-dark-gray-c-eleven py-4 text-slate-400 opacity-80"
onClick={() => editor?.commands.focus()}
>
{editor && (
<BubbleMenu
className="flex gap-2 rounded-lg bg-transparent px-2"
editor={editor}
tippyOptions={{
placement: 'auto',
offset: [0, 10],
interactive: true,
interactiveBorder: 20,
onHidden: () => {
setShowAIPopup(false)
setSelectedRange(null)
},
maxWidth: 'none',
}}
>
<div
className="w-[300px]"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseUp={(e) => {
e.preventDefault()
e.stopPropagation()

if (!showAIPopup) {
setSelectedRange({
from: editor.state.selection.from,
to: editor.state.selection.to,
})
}
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
{showAIPopup ? (
<div className="ai-edit-menu">

Check warning on line 120 in src/components/Editor/EditorManager.tsx

View workflow job for this annotation

GitHub Actions / build_and_package (macos-13)

Classname 'ai-edit-menu' is not a Tailwind CSS class!

Check warning on line 120 in src/components/Editor/EditorManager.tsx

View workflow job for this annotation

GitHub Actions / build_and_package (macos-latest)

Classname 'ai-edit-menu' is not a Tailwind CSS class!

Check warning on line 120 in src/components/Editor/EditorManager.tsx

View workflow job for this annotation

GitHub Actions / build_and_package (windows-latest)

Classname 'ai-edit-menu' is not a Tailwind CSS class!

Check warning on line 120 in src/components/Editor/EditorManager.tsx

View workflow job for this annotation

GitHub Actions / build_and_package (ubuntu-latest, x64)

Classname 'ai-edit-menu' is not a Tailwind CSS class!
<AiEditMenu
selectedText={turndownService.turndown(
getHTMLFromFragment(
editor.state.doc.slice(
selectedRange?.from || editor.state.selection.from,
selectedRange?.to || editor.state.selection.to,
).content,
editor.schema,
),
)}
onEdit={(newText: string) => {
editor
.chain()
.focus()
.deleteRange({
from: selectedRange?.from || editor.state.selection.from,
to: selectedRange?.to || editor.state.selection.to,
})
.insertContent(newText)
.run()
setShowAIPopup(false)
}}
/>
</div>
) : (
<button onClick={() => setShowAIPopup(true)} className="rounded p-2 hover:bg-gray-700">
AI Edit
</button>
)}
</div>
</BubbleMenu>
)}
<SearchBar editor={editor} showSearch={showSearchBar} setShowSearch={setShowSearchBar} />
{contextMenuVisible && (
<EditorContextMenu
Expand All @@ -82,33 +160,19 @@
/>
)}

<div
className={`relative h-full ${editorFlex ? 'flex justify-center py-4 pl-4' : ''} ${showDocumentStats ? 'pb-3' : ''}`}
>
<div className={`relative h-full ${editorFlex ? 'flex justify-center py-4 pl-4' : ''}`}>
<div className="relative size-full overflow-y-auto">
<EditorContent
className={`relative size-full bg-dark-gray-c-eleven ${editorFlex ? 'max-w-xl' : ''}`}
style={{
wordBreak: 'break-word',
}}
onContextMenu={handleContextMenu}
onClick={handleClick}
editor={editor}
/>
</div>
</div>
{suggestionsState && (
<InEditorBacklinkSuggestionsDisplay
suggestionsState={suggestionsState}
suggestions={vaultFilesFlattened.map((file) => file.relativePath)}
/>
)}
{editor && showDocumentStats && (
<div className="absolute bottom-2 right-2 flex gap-4 text-sm text-gray-500">
<div>Characters: {editor.storage.characterCount.characters()}</div>
<div>Words: {editor.storage.characterCount.words()}</div>
</div>
)}
<DocumentStats editor={editor} />
</div>
)
}
Expand Down
25 changes: 25 additions & 0 deletions src/components/Editor/Extensions/CustomHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// ... existing imports ...
import { Extension } from '@tiptap/core'

// Add this custom extension configuration before the EditorManager component
const CustomHighlight = Extension.create({
name: 'customHighlight',
addGlobalAttributes() {
return [
{
types: ['highlight'],
attributes: {
class: {
default: 'selection-highlight',
parseHTML: () => 'selection-highlight',
renderHTML: () => ({
class: 'selection-highlight',
}),
},
},
},
]
},
})

export default CustomHighlight
Loading
Loading