feat: wiki like linkage for posts

This commit is contained in:
2026-02-27 16:36:45 +01:00
parent f9527b384b
commit bd10825e74
12 changed files with 390 additions and 18 deletions

View File

@@ -847,6 +847,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
content={content}
onChange={setContent}
placeholder={tr('editor.placeholder.startWriting')}
currentPostTags={tags}
currentPostCategories={selectedCategories}
/>
)}
@@ -921,6 +923,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onInsertLink={handleInsertLink}
onInsertImage={() => {}}
onClose={() => setShowPostSearch(false)}
currentPostTags={tags}
currentPostCategories={selectedCategories}
/>
)}

View File

@@ -240,3 +240,58 @@
.insert-modal-results::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted, #555);
}
/* Create post option */
.insert-modal-result-create {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
border: none;
border-top: 1px solid var(--color-border, #3c3c3c);
margin-top: 4px;
padding-top: 16px;
background: transparent;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
font-family: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
border-radius: 0 0 4px 4px;
transition: background-color 0.15s ease;
}
.insert-modal-result-create:first-child {
border-top: none;
margin-top: 0;
padding-top: 12px;
}
.insert-modal-result-create:hover,
.insert-modal-result-create.selected {
background: var(--color-bg-tertiary, #2a2a2a);
}
.insert-modal-result-create.selected {
border-left: 3px solid var(--vscode-notificationsInfoIcon-foreground, #75beff);
padding-left: 13px;
}
.insert-modal-result-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.insert-modal-create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}

View File

@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../i18n';
import { useAppStore } from '../../store/appStore';
import { showToast } from '../Toast';
import './InsertModal.css';
interface PostSearchResult {
@@ -20,8 +22,8 @@ interface MediaSearchResult {
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
function getMediaDisplayName(media: MediaSearchResult): string {
if (media.title) {
return media.title.length > 60
? media.title.substring(0, 60) + '...'
return media.title.length > 60
? media.title.substring(0, 60) + '...'
: media.title;
}
return media.originalName;
@@ -38,6 +40,8 @@ interface InsertModalProps {
onInsertImage: (url: string, alt: string, mediaId?: string) => void;
onClose: () => void;
initialText?: string; // Selected text in editor
currentPostTags?: string[];
currentPostCategories?: string[];
}
function isPostResult(result: SearchResult): result is PostSearchResult {
@@ -54,8 +58,11 @@ export const InsertModal: React.FC<InsertModalProps> = ({
onInsertImage,
onClose,
initialText = '',
currentPostTags,
currentPostCategories,
}) => {
const { t: tr } = useI18n();
const openTabInBackground = useAppStore((s) => s.openTabInBackground);
const [activeTab, setActiveTab] = useState<Tab>('internal');
const [query, setQuery] = useState('');
const [externalUrl, setExternalUrl] = useState('');
@@ -64,9 +71,20 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(null);
// Whether to show the "Create post" option
const showCreateOption = mode === 'link' &&
activeTab === 'internal' &&
query.trim().length >= 2 &&
!isSearching &&
!results.some(r => isPostResult(r) && r.title.toLowerCase() === query.trim().toLowerCase());
// Total selectable items count (results + optional create option)
const totalItems = results.length + (showCreateOption ? 1 : 0);
// Focus appropriate input on mount and tab change
useEffect(() => {
if (activeTab === 'internal') {
@@ -106,6 +124,34 @@ export const InsertModal: React.FC<InsertModalProps> = ({
return () => clearTimeout(timeoutId);
}, [query, mode, activeTab]);
// Handle creating a new post from the search query
const handleCreatePost = useCallback(async () => {
const title = query.trim();
if (!title || isCreating) return;
setIsCreating(true);
try {
const newPost = await window.electronAPI.posts.create({
title,
tags: currentPostTags || [],
categories: currentPostCategories || [],
});
if (newPost) {
openTabInBackground({ type: 'post', id: newPost.id, isTransient: false });
const linkUrl = `/posts/${newPost.slug}`;
onInsertLink(linkUrl, title);
showToast.success(tr('insert.createdPost', { title }));
onClose();
}
} catch (error) {
const err = error as Error;
showToast.error(err.message);
} finally {
setIsCreating(false);
}
}, [query, isCreating, currentPostTags, currentPostCategories, openTabInBackground, onInsertLink, onClose, tr]);
// Keyboard navigation handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
@@ -116,7 +162,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
case 'ArrowDown':
if (activeTab === 'internal') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
setSelectedIndex(prev => Math.min(prev + 1, totalItems - 1));
}
break;
case 'ArrowUp':
@@ -127,9 +173,13 @@ export const InsertModal: React.FC<InsertModalProps> = ({
break;
case 'Enter':
e.preventDefault();
if (activeTab === 'internal' && results[selectedIndex]) {
handleSelectResult(results[selectedIndex]);
} else if (activeTab === 'external' && externalUrl) {
if (activeTab === 'internal') {
if (selectedIndex < results.length && results[selectedIndex]) {
handleSelectResult(results[selectedIndex]);
} else if (showCreateOption && selectedIndex === results.length) {
handleCreatePost();
}
} else if (externalUrl) {
handleExternalSubmit();
}
break;
@@ -137,7 +187,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
// Allow tab switching with Tab key when on the tab buttons
break;
}
}, [activeTab, results, selectedIndex, externalUrl, onClose]);
}, [activeTab, results, selectedIndex, totalItems, showCreateOption, externalUrl, onClose, handleCreatePost]);
// Handle selecting a search result
const handleSelectResult = useCallback(async (result: SearchResult) => {
@@ -161,7 +211,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
// Handle external URL submission
const handleExternalSubmit = useCallback(() => {
if (!externalUrl) return;
if (mode === 'link') {
onInsertLink(externalUrl, externalText || undefined);
} else {
@@ -180,7 +230,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
// Scroll selected item into view
useEffect(() => {
const selectedElement = document.querySelector('.insert-modal-result-item.selected');
const selectedElement = document.querySelector('.insert-modal-result-item.selected, .insert-modal-result-create.selected');
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
@@ -189,7 +239,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image');
const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal');
const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal');
const searchPlaceholder = mode === 'link'
const searchPlaceholder = mode === 'link'
? tr('insert.searchPlaceholder.link')
: tr('insert.searchPlaceholder.image');
@@ -224,6 +274,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
placeholder={searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
autoComplete="off"
/>
</div>
@@ -239,7 +290,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
</div>
)}
{!isSearching && query.length >= 2 && results.length === 0 && (
{!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
<div className="insert-modal-status">
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
</div>
@@ -274,6 +325,19 @@ export const InsertModal: React.FC<InsertModalProps> = ({
)}
</div>
))}
{showCreateOption && (
<button
type="button"
className={`insert-modal-result-create ${selectedIndex === results.length ? 'selected' : ''}`}
onClick={handleCreatePost}
onMouseEnter={() => setSelectedIndex(results.length)}
disabled={isCreating}
>
<span className="insert-modal-create-icon">+</span>
<span>{tr('insert.createPost', { title: query.trim() })}</span>
</button>
)}
</div>
</>
) : (
@@ -290,7 +354,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
autoComplete="off"
/>
</div>
{mode === 'link' ? (
<div className="insert-modal-field">
<label className="insert-modal-label">{tr('insert.label.linkTextOptional')}</label>
@@ -328,7 +392,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
<div className="insert-modal-footer">
<div className="insert-modal-footer-content">
<span className="insert-modal-hint">
{activeTab === 'internal'
{activeTab === 'internal'
? tr('insert.hint.internal')
: tr('insert.hint.external')}
</span>

View File

@@ -54,6 +54,8 @@ interface MilkdownEditorProps {
content: string;
onChange: (markdown: string) => void;
placeholder?: string;
currentPostTags?: string[];
currentPostCategories?: string[];
}
interface MilkdownChangePropagationInput {
@@ -86,10 +88,12 @@ export const shouldPropagateMilkdownChange = ({
interface EditorToolbarProps {
onUserInteraction: () => void;
currentPostTags?: string[];
currentPostCategories?: string[];
}
// Toolbar component that uses the editor instance
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction, currentPostTags, currentPostCategories }) => {
const { t: tr } = useI18n();
const [loading, getEditor] = useInstance();
const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
@@ -269,6 +273,8 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
onInsertImage={handleInsertImage}
onClose={() => setInsertMode(null)}
initialText={selectedText}
currentPostTags={currentPostTags}
currentPostCategories={currentPostCategories}
/>
)}
</>
@@ -289,6 +295,8 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
content,
onChange,
placeholder,
currentPostTags,
currentPostCategories,
}) => {
const { t: tr } = useI18n();
const resolvedPlaceholder = placeholder || tr('editor.placeholder');
@@ -376,7 +384,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
onPasteCapture={markUserInteraction}
onInputCapture={markUserInteraction}
>
<EditorToolbar onUserInteraction={markUserInteraction} />
<EditorToolbar onUserInteraction={markUserInteraction} currentPostTags={currentPostTags} currentPostCategories={currentPostCategories} />
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
<Milkdown />
</div>