feat: linking to images via ui

This commit is contained in:
2026-02-14 15:33:02 +01:00
parent 02b93ff5c5
commit ce94d22d30
12 changed files with 891 additions and 65 deletions

View File

@@ -0,0 +1,229 @@
.insert-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;
}
.insert-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);
}
.insert-modal-header {
padding: 16px 20px 0;
border-bottom: 1px solid var(--color-border, #3c3c3c);
}
.insert-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text, #fff);
margin: 0 0 12px 0;
}
.insert-modal-tabs {
display: flex;
gap: 0;
margin: 0 -20px;
border-bottom: none;
}
.insert-modal-tab {
flex: 1;
padding: 10px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted, #888);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.insert-modal-tab:hover {
color: var(--color-text, #ccc);
background: var(--color-bg-tertiary, #252526);
}
.insert-modal-tab.active {
color: var(--color-text, #fff);
border-bottom-color: var(--color-primary, #0e639c);
background: var(--color-bg-tertiary, #252526);
}
.insert-modal-search {
border-bottom: 1px solid var(--color-border, #3c3c3c);
}
.insert-modal-input {
width: 100%;
padding: 14px 20px;
font-size: 14px;
background: transparent;
border: none;
color: var(--color-text, #ccc);
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.insert-modal-input::placeholder {
color: var(--color-text-muted, #888);
}
.insert-modal-results {
flex: 1;
overflow-y: auto;
padding: 8px;
min-height: 200px;
}
.insert-modal-status {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--color-text-muted, #888);
font-size: 14px;
text-align: center;
}
.insert-modal-result-item {
padding: 12px 16px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 4px;
transition: background-color 0.15s ease;
}
.insert-modal-result-item:hover,
.insert-modal-result-item.selected {
background: var(--color-bg-tertiary, #2a2a2a);
}
.insert-modal-result-item.selected {
border-left: 3px solid var(--color-primary, #0e639c);
padding-left: 13px;
}
.insert-modal-result-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text, #fff);
margin-bottom: 4px;
}
.insert-modal-result-excerpt {
font-size: 12px;
color: var(--color-text-muted, #888);
line-height: 1.4;
margin-bottom: 4px;
}
.insert-modal-result-path,
.insert-modal-result-meta {
font-size: 11px;
color: var(--color-text-muted, #666);
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
}
.insert-modal-external {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 200px;
}
.insert-modal-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.insert-modal-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.insert-modal-field .insert-modal-input {
padding: 10px 12px;
border: 1px solid var(--color-border, #3c3c3c);
border-radius: 4px;
background: var(--color-bg-primary, #1a1a1a);
}
.insert-modal-field .insert-modal-input:focus {
border-color: var(--color-primary, #0e639c);
}
.insert-modal-submit {
margin-top: 8px;
padding: 10px 20px;
background: var(--color-primary, #0e639c);
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
align-self: flex-start;
}
.insert-modal-submit:hover:not(:disabled) {
background: var(--color-primary-hover, #1177bb);
}
.insert-modal-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.insert-modal-footer {
border-top: 1px solid var(--color-border, #3c3c3c);
padding: 8px 16px;
display: flex;
justify-content: center;
}
.insert-modal-hint {
font-size: 11px;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Scrollbar styling */
.insert-modal-results::-webkit-scrollbar {
width: 8px;
}
.insert-modal-results::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #1e1e1e);
}
.insert-modal-results::-webkit-scrollbar-thumb {
background: var(--color-border, #3c3c3c);
border-radius: 4px;
}
.insert-modal-results::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted, #555);
}

View File

@@ -0,0 +1,323 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import './InsertModal.css';
interface PostSearchResult {
id: string;
title: string;
slug: string;
excerpt?: string;
}
interface MediaSearchResult {
id: string;
originalName: string;
mimeType: string;
createdAt: string;
}
type SearchResult = PostSearchResult | MediaSearchResult;
type InsertMode = 'link' | 'image';
type Tab = 'external' | 'internal';
interface InsertModalProps {
mode: InsertMode;
onInsertLink: (url: string, text?: string) => void;
onInsertImage: (url: string, alt: string) => void;
onClose: () => void;
initialText?: string; // Selected text in editor
}
function isPostResult(result: SearchResult): result is PostSearchResult {
return 'title' in result;
}
function isMediaResult(result: SearchResult): result is MediaSearchResult {
return 'originalName' in result;
}
export const InsertModal: React.FC<InsertModalProps> = ({
mode,
onInsertLink,
onInsertImage,
onClose,
initialText = '',
}) => {
const [activeTab, setActiveTab] = useState<Tab>('internal');
const [query, setQuery] = useState('');
const [externalUrl, setExternalUrl] = useState('');
const [externalText, setExternalText] = useState(initialText);
const [externalAlt, setExternalAlt] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(null);
// Focus appropriate input on mount and tab change
useEffect(() => {
if (activeTab === 'internal') {
inputRef.current?.focus();
} else {
externalUrlRef.current?.focus();
}
}, [activeTab]);
// Debounced search effect
useEffect(() => {
if (activeTab !== 'internal' || query.length < 2) {
setResults([]);
setSelectedIndex(0);
return;
}
const timeoutId = setTimeout(async () => {
setIsSearching(true);
try {
if (mode === 'link') {
const searchResults = await window.electronAPI.posts.search(query);
setResults(searchResults || []);
} else {
const searchResults = await window.electronAPI.media.search(query);
setResults(searchResults || []);
}
setSelectedIndex(0);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timeoutId);
}, [query, mode, activeTab]);
// Keyboard navigation handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
onClose();
break;
case 'ArrowDown':
if (activeTab === 'internal') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
}
break;
case 'ArrowUp':
if (activeTab === 'internal') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
}
break;
case 'Enter':
e.preventDefault();
if (activeTab === 'internal' && results[selectedIndex]) {
handleSelectResult(results[selectedIndex]);
} else if (activeTab === 'external' && externalUrl) {
handleExternalSubmit();
}
break;
case 'Tab':
// Allow tab switching with Tab key when on the tab buttons
break;
}
}, [activeTab, results, selectedIndex, externalUrl, onClose]);
// Handle selecting a search result
const handleSelectResult = useCallback(async (result: SearchResult) => {
if (mode === 'link' && isPostResult(result)) {
const linkUrl = `/posts/${result.slug}`;
const linkText = initialText || result.title;
onInsertLink(linkUrl, linkText);
} else if (mode === 'image' && isMediaResult(result)) {
// Get the media URL
const url = await window.electronAPI.media.getUrl(result.id);
if (url) {
// Extract filename without extension for alt text
const altText = result.originalName.replace(/\.[^.]+$/, '');
onInsertImage(url, altText);
}
}
onClose();
}, [mode, initialText, onInsertLink, onInsertImage, onClose]);
// Handle external URL submission
const handleExternalSubmit = useCallback(() => {
if (!externalUrl) return;
if (mode === 'link') {
onInsertLink(externalUrl, externalText || undefined);
} else {
onInsertImage(externalUrl, externalAlt || 'Image');
}
onClose();
}, [mode, externalUrl, externalText, externalAlt, onInsertLink, onInsertImage, onClose]);
// Backdrop click handler
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
// Scroll selected item into view
useEffect(() => {
const selectedElement = document.querySelector('.insert-modal-result-item.selected');
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selectedIndex]);
const title = mode === 'link' ? 'Insert Link' : 'Insert Image';
const internalLabel = mode === 'link' ? 'Link to Post' : 'Media Library';
const externalLabel = mode === 'link' ? 'External URL' : 'External Image';
const searchPlaceholder = mode === 'link'
? 'Search posts by title or content...'
: 'Search media by name, caption, or alt text...';
return (
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>
<div className="insert-modal" onKeyDown={handleKeyDown}>
<div className="insert-modal-header">
<h3 className="insert-modal-title">{title}</h3>
<div className="insert-modal-tabs">
<button
className={`insert-modal-tab ${activeTab === 'internal' ? 'active' : ''}`}
onClick={() => setActiveTab('internal')}
>
{internalLabel}
</button>
<button
className={`insert-modal-tab ${activeTab === 'external' ? 'active' : ''}`}
onClick={() => setActiveTab('external')}
>
{externalLabel}
</button>
</div>
</div>
{activeTab === 'internal' ? (
<>
<div className="insert-modal-search">
<input
ref={inputRef}
type="text"
className="insert-modal-input"
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
/>
</div>
<div className="insert-modal-results">
{isSearching && (
<div className="insert-modal-status">Searching...</div>
)}
{!isSearching && query.length < 2 && (
<div className="insert-modal-status">
Type at least 2 characters to search
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
<div className="insert-modal-status">
No {mode === 'link' ? 'posts' : 'media'} found for "{query}"
</div>
)}
{!isSearching && results.length > 0 && results.map((result, index) => (
<div
key={isPostResult(result) ? result.id : result.id}
className={`insert-modal-result-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelectResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
{isPostResult(result) ? (
<>
<div className="insert-modal-result-title">{result.title}</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120
? result.excerpt.substring(0, 120) + '...'
: result.excerpt}
</div>
)}
<div className="insert-modal-result-path">/posts/{result.slug}</div>
</>
) : (
<>
<div className="insert-modal-result-title">{result.originalName}</div>
<div className="insert-modal-result-meta">
{result.mimeType} {new Date(result.createdAt).toLocaleDateString()}
</div>
</>
)}
</div>
))}
</div>
</>
) : (
<div className="insert-modal-external">
<div className="insert-modal-field">
<label className="insert-modal-label">URL</label>
<input
ref={externalUrlRef}
type="text"
className="insert-modal-input"
placeholder={mode === 'link' ? 'https://example.com' : 'https://example.com/image.jpg'}
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
autoComplete="off"
/>
</div>
{mode === 'link' ? (
<div className="insert-modal-field">
<label className="insert-modal-label">Link Text (optional)</label>
<input
type="text"
className="insert-modal-input"
placeholder="Click here"
value={externalText}
onChange={(e) => setExternalText(e.target.value)}
/>
</div>
) : (
<div className="insert-modal-field">
<label className="insert-modal-label">Alt Text</label>
<input
type="text"
className="insert-modal-input"
placeholder="Description of the image"
value={externalAlt}
onChange={(e) => setExternalAlt(e.target.value)}
/>
</div>
)}
<button
className="insert-modal-submit"
onClick={handleExternalSubmit}
disabled={!externalUrl}
>
Insert {mode === 'link' ? 'Link' : 'Image'}
</button>
</div>
)}
<div className="insert-modal-footer">
<span className="insert-modal-hint">
{activeTab === 'internal'
? 'Use ↑↓ to navigate, Enter to select, Esc to close'
: 'Enter URL and press Enter or click button, Esc to close'}
</span>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -21,7 +21,7 @@ import { imageResolverPlugin } from '../../plugins/imageResolverPlugin';
// Import macros module to register all macro definitions
import '../../macros';
import './MilkdownEditor.css';
import { PostSearchModal } from '../PostSearchModal';
import { InsertModal } from '../InsertModal';
import { unescapeMacroSyntax } from '../../utils/markdownEscape';
// Remark plugin to force tight lists (no blank lines between list items)
@@ -47,12 +47,7 @@ const remarkTightLists: RemarkPlugin = {
options: {},
};
interface SearchResult {
id: string;
title: string;
slug: string;
excerpt?: string;
}
type InsertModalMode = 'link' | 'image' | null;
interface MilkdownEditorProps {
content: string;
@@ -63,7 +58,8 @@ interface MilkdownEditorProps {
// Toolbar component that uses the editor instance
const EditorToolbar: React.FC = () => {
const [loading, getEditor] = useInstance();
const [showPostSearch, setShowPostSearch] = useState(false);
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
const [selectedText, setSelectedText] = useState('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -101,37 +97,48 @@ const EditorToolbar: React.FC = () => {
});
}, [loading, getEditor]);
const insertLink = useCallback(() => {
const url = window.prompt('Enter URL:');
if (!url) return;
runCommand(toggleLinkCommand.key, { href: url });
}, [runCommand]);
// Get current selection text from editor
const getSelectionText = useCallback(() => {
const editor = getEditor();
if (!editor) return '';
let text = '';
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const { state } = view;
const { selection } = state;
if (!selection.empty) {
text = state.doc.textBetween(selection.from, selection.to);
}
});
return text;
}, [getEditor]);
const insertImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
if (!url) return;
const alt = window.prompt('Enter alt text:', 'Image') || 'Image';
runCommand(insertImageCommand.key, { src: url, alt });
}, [runCommand]);
const openLinkModal = useCallback(() => {
const text = getSelectionText();
setSelectedText(text);
setInsertMode('link');
}, [getSelectionText]);
const insertPostLink = useCallback(() => {
setShowPostSearch(true);
const openImageModal = useCallback(() => {
setSelectedText('');
setInsertMode('image');
}, []);
// Add keyboard shortcut listener for Ctrl/Cmd+K
// Add keyboard shortcut listener for Ctrl/Cmd+K (link)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setShowPostSearch(true);
openLinkModal();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
}, [openLinkModal]);
const handlePostSelected = useCallback((post: SearchResult) => {
// Handle link insertion from modal
const handleInsertLink = useCallback((url: string, text?: string) => {
const editor = getEditor();
if (!editor) return;
@@ -139,10 +146,10 @@ const EditorToolbar: React.FC = () => {
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 currentSelectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to);
const linkText = selectedText || post.title;
const linkUrl = `/posts/${post.slug}`;
const linkText = currentSelectedText || text || url;
const linkUrl = url;
if (selection.empty) {
// No selection - create text node with link mark and insert it
@@ -158,9 +165,15 @@ const EditorToolbar: React.FC = () => {
}
});
setShowPostSearch(false);
setInsertMode(null);
}, [getEditor]);
// Handle image insertion from modal
const handleInsertImage = useCallback((url: string, alt: string) => {
runCommand(insertImageCommand.key, { src: url, alt });
setInsertMode(null);
}, [runCommand]);
if (loading) return null;
return (
@@ -198,9 +211,8 @@ const EditorToolbar: React.FC = () => {
<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={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
<button onClick={openImageModal} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
</div>
@@ -212,10 +224,13 @@ const EditorToolbar: React.FC = () => {
</div>
</div>
{showPostSearch && (
<PostSearchModal
onSelect={handlePostSelected}
onClose={() => setShowPostSearch(false)}
{insertMode && (
<InsertModal
mode={insertMode}
onInsertLink={handleInsertLink}
onInsertImage={handleInsertImage}
onClose={() => setInsertMode(null)}
initialText={selectedText}
/>
)}
</>

View File

@@ -20,3 +20,4 @@ export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
export { ChatPanel } from './ChatPanel';
export { ImportAnalysisView } from './ImportAnalysisView';
export { InsertModal } from './InsertModal';