feat: easy post-links via search

This commit is contained in:
2026-02-12 22:29:36 +01:00
parent 91111c7572
commit dc4e8749d7
6 changed files with 472 additions and 49 deletions

View File

@@ -13,8 +13,16 @@ import { TagInput } from '../TagInput';
import { ChatPanel } from '../ChatPanel'; import { ChatPanel } from '../ChatPanel';
import { AutoSaveManager } from '../../utils'; import { AutoSaveManager } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry'; import { parseMacros, getMacro } from '../../macros/registry';
import { PostSearchModal } from '../PostSearchModal';
import './Editor.css'; import './Editor.css';
interface SearchResult {
id: string;
title: string;
slug: string;
excerpt?: string;
}
// Module-level AutoSaveManager for idle-time based auto-saving // Module-level AutoSaveManager for idle-time based auto-saving
const autoSaveManager = new AutoSaveManager({ const autoSaveManager = new AutoSaveManager({
idleTimeMs: 3000, // Save after 3 seconds of idle time idleTimeMs: 3000, // Save after 3 seconds of idle time
@@ -254,6 +262,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxIndex, setLightboxIndex] = useState(0);
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]); const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
const [showPostSearch, setShowPostSearch] = useState(false);
const editorRef = useRef<unknown>(null); const editorRef = useRef<unknown>(null);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
@@ -522,10 +531,45 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
}; };
// Handle Monaco editor mount // Handle Monaco editor mount
const handleEditorDidMount = (editor: unknown) => { const handleEditorDidMount = (editor: unknown, monaco: Monaco) => {
editorRef.current = editor; 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 // Configure Monaco before mount to add macro syntax highlighting
const handleEditorWillMount = (monaco: Monaco) => { const handleEditorWillMount = (monaco: Monaco) => {
// Register a custom language that extends markdown with macro support // Register a custom language that extends markdown with macro support
@@ -698,8 +742,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
</div> </div>
</div> </div>
<PostLinks <PostLinks
postId={post.id} postId={post.id}
updatedAt={post.updatedAt}
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)} onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
/> />
</div> </div>
@@ -736,7 +781,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
</button> </button>
</div> </div>
{images.length > 0 && ( {images.length > 0 && (
<button <button
className="gallery-button" className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }} onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`} title={`View ${images.length} image(s)`}
@@ -744,6 +789,15 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
📷 {images.length} 📷 {images.length}
</button> </button>
)} )}
{editorMode === 'markdown' && (
<button
className="insert-post-link-button"
onClick={() => setShowPostSearch(true)}
title="Link to post (Ctrl+K)"
>
📝
</button>
)}
</div> </div>
{editorMode === 'wysiwyg' && ( {editorMode === 'wysiwyg' && (
@@ -813,6 +867,13 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
</span> </span>
)} )}
</div> </div>
{showPostSearch && (
<PostSearchModal
onSelect={handlePostSelected}
onClose={() => setShowPostSearch(false)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -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 { 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 { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInOrderedListCommand, insertHrCommand, toggleInlineCodeCommand, insertImageCommand, toggleLinkCommand } from '@milkdown/kit/preset/commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm'; 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 module to register all macro definitions
import '../../macros'; import '../../macros';
import './MilkdownEditor.css'; import './MilkdownEditor.css';
import { PostSearchModal } from '../PostSearchModal';
/** /**
* Unescape brackets that Milkdown/remark escapes. * Unescape brackets that Milkdown/remark escapes.
@@ -53,6 +54,13 @@ const remarkTightLists: RemarkPlugin = {
options: {}, options: {},
}; };
interface SearchResult {
id: string;
title: string;
slug: string;
excerpt?: string;
}
interface MilkdownEditorProps { interface MilkdownEditorProps {
content: string; content: string;
onChange: (markdown: string) => void; onChange: (markdown: string) => void;
@@ -62,6 +70,7 @@ interface MilkdownEditorProps {
// Toolbar component that uses the editor instance // Toolbar component that uses the editor instance
const EditorToolbar: React.FC = () => { const EditorToolbar: React.FC = () => {
const [loading, getEditor] = useInstance(); 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
// 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(insertImageCommand.key, { src: url, alt });
}, [runCommand]); }, [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; if (loading) return null;
return ( return (
<div className="milkdown-toolbar"> <>
<div className="toolbar-group"> <div className="milkdown-toolbar">
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button> <div className="toolbar-group">
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button> <button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button> <button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
<s>S</s>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List"></button>
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote"></button>
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={insertLink} title="Insert Link">🔗</button>
<button onClick={insertPostLink} title="Link to Post (Ctrl+K)">📝</button>
<button onClick={insertImage} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)"></button>
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)"></button>
</div>
</div> </div>
<div className="toolbar-divider" /> {showPostSearch && (
<PostSearchModal
<div className="toolbar-group"> onSelect={handlePostSelected}
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)"> onClose={() => setShowPostSearch(false)}
<strong>B</strong> />
</button> )}
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)"> </>
<em>I</em>
</button>
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
<s>S</s>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List"></button>
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote"></button>
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={insertLink} title="Insert Link">🔗</button>
<button onClick={insertImage} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)"></button>
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)"></button>
</div>
</div>
); );
}; };

View File

@@ -10,9 +10,10 @@ interface PostLinkInfo {
interface PostLinksProps { interface PostLinksProps {
postId: string; postId: string;
onPostClick?: (postId: string) => void; onPostClick?: (postId: string) => void;
updatedAt?: string; // Trigger reload when post is saved
} }
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) => { export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick, updatedAt }) => {
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]); const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]); const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -36,7 +37,7 @@ export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) =>
}; };
loadLinks(); loadLinks();
}, [postId]); }, [postId, updatedAt]); // Reload when post is updated
const totalLinks = linksTo.length + linkedBy.length; const totalLinks = linksTo.length + linkedBy.length;

View File

@@ -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);
}

View File

@@ -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<PostSearchModalProps> = ({
onSelect,
onClose,
initialQuery = ''
}) => {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="post-search-modal-backdrop" onClick={handleBackdropClick}>
<div className="post-search-modal" onKeyDown={handleKeyDown}>
<div className="post-search-header">
<input
ref={inputRef}
type="text"
className="post-search-input"
placeholder="Search posts by title or content..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
/>
</div>
<div className="post-search-results">
{isSearching && (
<div className="post-search-loading">
Searching...
</div>
)}
{!isSearching && query.length < 2 && (
<div className="post-search-empty">
Type at least 2 characters to search
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
<div className="post-search-empty">
No posts found for "{query}"
</div>
)}
{!isSearching && results.length > 0 && results.map((post, index) => (
<div
key={post.id}
className={`post-search-result-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleResultClick(post)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="post-search-result-title">{post.title}</div>
{post.excerpt && (
<div className="post-search-result-excerpt">
{post.excerpt.length > 120
? post.excerpt.substring(0, 120) + '...'
: post.excerpt}
</div>
)}
<div className="post-search-result-slug">/posts/{post.slug}</div>
</div>
))}
</div>
<div className="post-search-footer">
<span className="post-search-hint">
Use to navigate, Enter to select, Esc to close
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { PostSearchModal } from './PostSearchModal';