diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 002accc..0a8b2c0 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -13,8 +13,16 @@ import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; import { AutoSaveManager } from '../../utils'; import { parseMacros, getMacro } from '../../macros/registry'; +import { PostSearchModal } from '../PostSearchModal'; import './Editor.css'; +interface SearchResult { + id: string; + title: string; + slug: string; + excerpt?: string; +} + // Module-level AutoSaveManager for idle-time based auto-saving const autoSaveManager = new AutoSaveManager({ idleTimeMs: 3000, // Save after 3 seconds of idle time @@ -254,6 +262,7 @@ const PostEditor: React.FC = ({ post }) => { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]); + const [showPostSearch, setShowPostSearch] = useState(false); const editorRef = useRef(null); const previewRef = useRef(null); @@ -522,10 +531,45 @@ const PostEditor: React.FC = ({ post }) => { }; // Handle Monaco editor mount - const handleEditorDidMount = (editor: unknown) => { + const handleEditorDidMount = (editor: unknown, monaco: Monaco) => { editorRef.current = editor; + const ed = editor as any; + + // Add keyboard shortcut and command for inserting post links + ed.addAction({ + id: 'editor.action.insertPostLink', + label: 'Insert Link to Post', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], + run: () => { + setShowPostSearch(true); + } + }); }; + // Handle post selection from search modal + const handlePostSelected = useCallback((post: SearchResult) => { + const editor = editorRef.current as any; + if (!editor) return; + + const model = editor.getModel(); + if (!model) return; + + const selection = editor.getSelection(); + const selectedText = selection ? model.getValueInRange(selection) : ''; + + const linkText = selectedText || post.title; + const linkUrl = `/posts/${post.slug}`; + const linkMarkdown = `[${linkText}](${linkUrl})`; + + editor.executeEdits('insert-post-link', [{ + range: selection || editor.getSelection(), + text: linkMarkdown, + forceMoveMarkers: true + }]); + + setShowPostSearch(false); + }, []); + // Configure Monaco before mount to add macro syntax highlighting const handleEditorWillMount = (monaco: Monaco) => { // Register a custom language that extends markdown with macro support @@ -698,8 +742,9 @@ const PostEditor: React.FC = ({ post }) => { - useAppStore.getState().setSelectedPost(id)} /> @@ -736,7 +781,7 @@ const PostEditor: React.FC = ({ post }) => { {images.length > 0 && ( - )} + {editorMode === 'markdown' && ( + + )} {editorMode === 'wysiwyg' && ( @@ -813,6 +867,13 @@ const PostEditor: React.FC = ({ post }) => { )} + + {showPostSearch && ( + setShowPostSearch(false)} + /> + )} ); }; diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 3a4a1e4..f44f594 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; import { Editor, defaultValueCtx, editorViewCtx, rootCtx, remarkStringifyOptionsCtx, remarkPluginsCtx } from '@milkdown/kit/core'; import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInOrderedListCommand, insertHrCommand, toggleInlineCodeCommand, insertImageCommand, toggleLinkCommand } from '@milkdown/kit/preset/commonmark'; import { gfm, toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm'; @@ -20,6 +20,7 @@ import { macroPlugin } from '../../plugins/macroPlugin'; // Import macros module to register all macro definitions import '../../macros'; import './MilkdownEditor.css'; +import { PostSearchModal } from '../PostSearchModal'; /** * Unescape brackets that Milkdown/remark escapes. @@ -53,6 +54,13 @@ const remarkTightLists: RemarkPlugin = { options: {}, }; +interface SearchResult { + id: string; + title: string; + slug: string; + excerpt?: string; +} + interface MilkdownEditorProps { content: string; onChange: (markdown: string) => void; @@ -62,6 +70,7 @@ interface MilkdownEditorProps { // Toolbar component that uses the editor instance const EditorToolbar: React.FC = () => { const [loading, getEditor] = useInstance(); + const [showPostSearch, setShowPostSearch] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,54 +121,111 @@ const EditorToolbar: React.FC = () => { runCommand(insertImageCommand.key, { src: url, alt }); }, [runCommand]); + const insertPostLink = useCallback(() => { + setShowPostSearch(true); + }, []); + + // Add keyboard shortcut listener for Ctrl/Cmd+K + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setShowPostSearch(true); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + const handlePostSelected = useCallback((post: SearchResult) => { + const editor = getEditor(); + if (!editor) return; + + editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const { state, dispatch } = view; + const { selection, schema } = state; + const selectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to); + + const linkText = selectedText || post.title; + const linkUrl = `/posts/${post.slug}`; + + if (selection.empty) { + // No selection - create text node with link mark and insert it + const linkMark = schema.marks.link.create({ href: linkUrl }); + const textNode = schema.text(linkText, [linkMark]); + const tr = state.tr.replaceSelectionWith(textNode, false); + dispatch(tr); + } else { + // Has selection - toggle link mark on selection + const linkMark = schema.marks.link.create({ href: linkUrl }); + const tr = state.tr.addMark(selection.from, selection.to, linkMark); + dispatch(tr); + } + }); + + setShowPostSearch(false); + }, [getEditor]); + if (loading) return null; return ( -
-
- - - + <> +
+
+ + + +
+ +
+ +
+ + + +
+ +
+ +
+ + + + +
+ +
+ +
+ + + + +
+ +
+ +
+ + +
-
- -
- - - -
- -
- -
- - - - -
- -
- -
- - - -
- -
- -
- - -
-
+ {showPostSearch && ( + setShowPostSearch(false)} + /> + )} + ); }; diff --git a/src/renderer/components/PostLinks/PostLinks.tsx b/src/renderer/components/PostLinks/PostLinks.tsx index c3136f8..6078428 100644 --- a/src/renderer/components/PostLinks/PostLinks.tsx +++ b/src/renderer/components/PostLinks/PostLinks.tsx @@ -10,9 +10,10 @@ interface PostLinkInfo { interface PostLinksProps { postId: string; onPostClick?: (postId: string) => void; + updatedAt?: string; // Trigger reload when post is saved } -export const PostLinks: React.FC = ({ postId, onPostClick }) => { +export const PostLinks: React.FC = ({ postId, onPostClick, updatedAt }) => { const [linksTo, setLinksTo] = useState([]); const [linkedBy, setLinkedBy] = useState([]); const [loading, setLoading] = useState(true); @@ -36,7 +37,7 @@ export const PostLinks: React.FC = ({ postId, onPostClick }) => }; loadLinks(); - }, [postId]); + }, [postId, updatedAt]); // Reload when post is updated const totalLinks = linksTo.length + linkedBy.length; diff --git a/src/renderer/components/PostSearchModal/PostSearchModal.css b/src/renderer/components/PostSearchModal/PostSearchModal.css new file mode 100644 index 0000000..fadfbbb --- /dev/null +++ b/src/renderer/components/PostSearchModal/PostSearchModal.css @@ -0,0 +1,130 @@ +.post-search-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.post-search-modal { + background: var(--color-bg-secondary, #1e1e1e); + border: 1px solid var(--color-border, #3c3c3c); + border-radius: 8px; + width: 600px; + max-height: 500px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.post-search-header { + border-bottom: 1px solid var(--color-border, #3c3c3c); +} + +.post-search-input { + width: 100%; + padding: 16px 20px; + font-size: 16px; + background: transparent; + border: none; + color: var(--color-text, #ccc); + outline: none; + font-family: inherit; +} + +.post-search-input::placeholder { + color: var(--color-text-muted, #888); +} + +.post-search-results { + flex: 1; + overflow-y: auto; + padding: 8px; + min-height: 200px; +} + +.post-search-loading, +.post-search-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--color-text-muted, #888); + font-size: 14px; + text-align: center; +} + +.post-search-result-item { + padding: 12px 16px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 4px; + transition: background-color 0.15s ease; +} + +.post-search-result-item:hover, +.post-search-result-item.selected { + background: var(--color-bg-tertiary, #2a2a2a); +} + +.post-search-result-item.selected { + border-left: 3px solid var(--color-primary, #0e639c); + padding-left: 13px; +} + +.post-search-result-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text, #fff); + margin-bottom: 4px; +} + +.post-search-result-excerpt { + font-size: 12px; + color: var(--color-text-muted, #888); + line-height: 1.4; + margin-bottom: 4px; +} + +.post-search-result-slug { + font-size: 11px; + color: var(--color-text-muted, #666); + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; +} + +.post-search-footer { + border-top: 1px solid var(--color-border, #3c3c3c); + padding: 8px 16px; + display: flex; + justify-content: center; +} + +.post-search-hint { + font-size: 11px; + color: var(--color-text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Scrollbar styling */ +.post-search-results::-webkit-scrollbar { + width: 8px; +} + +.post-search-results::-webkit-scrollbar-track { + background: var(--color-bg-secondary, #1e1e1e); +} + +.post-search-results::-webkit-scrollbar-thumb { + background: var(--color-border, #3c3c3c); + border-radius: 4px; +} + +.post-search-results::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted, #555); +} diff --git a/src/renderer/components/PostSearchModal/PostSearchModal.tsx b/src/renderer/components/PostSearchModal/PostSearchModal.tsx new file mode 100644 index 0000000..75a76d6 --- /dev/null +++ b/src/renderer/components/PostSearchModal/PostSearchModal.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import './PostSearchModal.css'; + +interface SearchResult { + id: string; + title: string; + slug: string; + excerpt?: string; +} + +interface PostSearchModalProps { + onSelect: (post: SearchResult) => void; + onClose: () => void; + initialQuery?: string; +} + +export const PostSearchModal: React.FC = ({ + onSelect, + onClose, + initialQuery = '' +}) => { + const [query, setQuery] = useState(initialQuery); + const [results, setResults] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const inputRef = useRef(null); + + // Focus search input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Debounced search effect + useEffect(() => { + if (query.length < 2) { + setResults([]); + setSelectedIndex(0); + return; + } + + const timeoutId = setTimeout(async () => { + setIsSearching(true); + try { + const searchResults = await window.electronAPI.posts.search(query); + setResults(searchResults || []); + setSelectedIndex(0); + } catch (error) { + console.error('Search failed:', error); + setResults([]); + } finally { + setIsSearching(false); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [query]); + + // Keyboard navigation handler + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'Escape': + e.preventDefault(); + onClose(); + break; + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (results[selectedIndex]) { + onSelect(results[selectedIndex]); + } + break; + } + }, [results, selectedIndex, onClose, onSelect]); + + // Backdrop click handler + const handleBackdropClick = useCallback((e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, [onClose]); + + // Result click handler + const handleResultClick = useCallback((post: SearchResult) => { + onSelect(post); + }, [onSelect]); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = document.querySelector('.post-search-result-item.selected'); + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selectedIndex]); + + return ( +
+
+
+ setQuery(e.target.value)} + autoComplete="off" + /> +
+ +
+ {isSearching && ( +
+ Searching... +
+ )} + + {!isSearching && query.length < 2 && ( +
+ Type at least 2 characters to search +
+ )} + + {!isSearching && query.length >= 2 && results.length === 0 && ( +
+ No posts found for "{query}" +
+ )} + + {!isSearching && results.length > 0 && results.map((post, index) => ( +
handleResultClick(post)} + onMouseEnter={() => setSelectedIndex(index)} + > +
{post.title}
+ {post.excerpt && ( +
+ {post.excerpt.length > 120 + ? post.excerpt.substring(0, 120) + '...' + : post.excerpt} +
+ )} +
/posts/{post.slug}
+
+ ))} +
+ +
+ + Use ↑↓ to navigate, Enter to select, Esc to close + +
+
+
+ ); +}; diff --git a/src/renderer/components/PostSearchModal/index.ts b/src/renderer/components/PostSearchModal/index.ts new file mode 100644 index 0000000..90b48b3 --- /dev/null +++ b/src/renderer/components/PostSearchModal/index.ts @@ -0,0 +1 @@ +export { PostSearchModal } from './PostSearchModal';