feat: linking to images via ui
This commit is contained in:
@@ -399,6 +399,18 @@ export class DatabaseConnection {
|
|||||||
console.log('FTS table migrated - rebuild index required');
|
console.log('FTS table migrated - rebuild index required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create FTS5 virtual table for media full-text search
|
||||||
|
// Stores: id (unindexed, for lookups), project_id (unindexed, for filtering),
|
||||||
|
// content (stemmed text from original_name, alt, caption, tags)
|
||||||
|
await this.localClient.execute(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5(
|
||||||
|
id UNINDEXED,
|
||||||
|
project_id UNINDEXED,
|
||||||
|
content,
|
||||||
|
content_rowid=rowid
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Migration: Ensure tags table exists (for databases created before tags feature)
|
// Migration: Ensure tags table exists (for databases created before tags feature)
|
||||||
const tagsTableExists = await this.localClient.execute(
|
const tagsTableExists = await this.localClient.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='tags'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='tags'"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media, Media, NewMedia, postMedia } from '../database/schema';
|
import { media, Media, NewMedia, postMedia } from '../database/schema';
|
||||||
|
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||||
|
|
||||||
// Thumbnail sizes
|
// Thumbnail sizes
|
||||||
const THUMBNAIL_SIZES = {
|
const THUMBNAIL_SIZES = {
|
||||||
@@ -68,11 +69,70 @@ export class MediaEngine extends EventEmitter {
|
|||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private dataDir: string | null = null; // For media files (may be external)
|
private dataDir: string | null = null; // For media files (may be external)
|
||||||
private internalDir: string | null = null; // For thumbnails (always local)
|
private internalDir: string | null = null; // For thumbnails (always local)
|
||||||
|
private searchLanguage: SupportedLanguage = 'english';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the language used for full-text search stemming.
|
||||||
|
*/
|
||||||
|
setSearchLanguage(language: SupportedLanguage): void {
|
||||||
|
this.searchLanguage = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current search language.
|
||||||
|
*/
|
||||||
|
getSearchLanguage(): SupportedLanguage {
|
||||||
|
return this.searchLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the FTS index for a media item.
|
||||||
|
* Stores stemmed content from original_name, alt, caption, and tags.
|
||||||
|
*/
|
||||||
|
private async updateFTSIndex(item: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
originalName: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
tags: string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
// Delete existing entry
|
||||||
|
await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [item.id] });
|
||||||
|
|
||||||
|
// Combine all searchable fields and stem them
|
||||||
|
const allText = [
|
||||||
|
item.originalName,
|
||||||
|
item.alt || '',
|
||||||
|
item.caption || '',
|
||||||
|
item.tags.join(' '),
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const stemmedContent = stemText(allText, this.searchLanguage);
|
||||||
|
|
||||||
|
// Insert with id, project_id, and stemmed content
|
||||||
|
await client.execute({
|
||||||
|
sql: 'INSERT INTO media_fts (id, project_id, content) VALUES (?, ?, ?)',
|
||||||
|
args: [item.id, item.projectId, stemmedContent],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a media item from the FTS index.
|
||||||
|
*/
|
||||||
|
private async deleteFTSIndex(id: string): Promise<void> {
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (!client) return;
|
||||||
|
await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [id] });
|
||||||
|
}
|
||||||
|
|
||||||
private getDefaultBaseDir(): string {
|
private getDefaultBaseDir(): string {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||||
@@ -466,6 +526,16 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
await db.insert(media).values(dbMedia);
|
await db.insert(media).values(dbMedia);
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
await this.updateFTSIndex({
|
||||||
|
id: mediaData.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
originalName: mediaData.originalName,
|
||||||
|
alt: mediaData.alt,
|
||||||
|
caption: mediaData.caption,
|
||||||
|
tags: mediaData.tags,
|
||||||
|
});
|
||||||
|
|
||||||
this.emit('mediaImported', mediaData);
|
this.emit('mediaImported', mediaData);
|
||||||
return mediaData;
|
return mediaData;
|
||||||
}
|
}
|
||||||
@@ -500,6 +570,16 @@ export class MediaEngine extends EventEmitter {
|
|||||||
})
|
})
|
||||||
.where(eq(media.id, id));
|
.where(eq(media.id, id));
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
await this.updateFTSIndex({
|
||||||
|
id: updated.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
originalName: updated.originalName,
|
||||||
|
alt: updated.alt,
|
||||||
|
caption: updated.caption,
|
||||||
|
tags: updated.tags,
|
||||||
|
});
|
||||||
|
|
||||||
this.emit('mediaUpdated', updated);
|
this.emit('mediaUpdated', updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -535,6 +615,9 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
await db.delete(media).where(eq(media.id, id));
|
await db.delete(media).where(eq(media.id, id));
|
||||||
|
|
||||||
|
// Delete from FTS index
|
||||||
|
await this.deleteFTSIndex(id);
|
||||||
|
|
||||||
this.emit('mediaDeleted', id);
|
this.emit('mediaDeleted', id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -660,37 +743,41 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async searchMedia(query: string): Promise<MediaSearchResult[]> {
|
async searchMedia(query: string): Promise<MediaSearchResult[]> {
|
||||||
const db = getDatabase().getLocal();
|
const client = getDatabase().getLocalClient();
|
||||||
const allMedia = await db
|
if (!client) return [];
|
||||||
.select()
|
|
||||||
.from(media)
|
|
||||||
.where(eq(media.projectId, this.currentProjectId))
|
|
||||||
.orderBy(desc(media.createdAt))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase();
|
try {
|
||||||
const searchResults: MediaSearchResult[] = [];
|
// Stem the query for multilingual matching
|
||||||
|
const stemmedQuery = stemQuery(query, this.searchLanguage);
|
||||||
|
|
||||||
|
// Search the stemmed content, filtered by project_id for project isolation
|
||||||
|
const result = await client.execute({
|
||||||
|
sql: `SELECT id FROM media_fts WHERE project_id = ? AND media_fts MATCH ? ORDER BY rank LIMIT 50`,
|
||||||
|
args: [this.currentProjectId, stemmedQuery],
|
||||||
|
});
|
||||||
|
|
||||||
for (const item of allMedia) {
|
// Fetch actual media data for results
|
||||||
// Search in originalName, alt, caption, and tags
|
const db = getDatabase().getLocal();
|
||||||
const searchableText = [
|
const searchResults: MediaSearchResult[] = [];
|
||||||
item.originalName,
|
|
||||||
item.alt || '',
|
for (const row of result.rows) {
|
||||||
item.caption || '',
|
const mediaId = row.id as string;
|
||||||
...(JSON.parse(item.tags || '[]') as string[]),
|
const item = await db.select().from(media).where(eq(media.id, mediaId)).get();
|
||||||
].join(' ').toLowerCase();
|
if (item) {
|
||||||
|
searchResults.push({
|
||||||
if (searchableText.includes(lowerQuery)) {
|
id: item.id,
|
||||||
searchResults.push({
|
originalName: item.originalName,
|
||||||
id: item.id,
|
mimeType: item.mimeType,
|
||||||
originalName: item.originalName,
|
createdAt: item.createdAt,
|
||||||
mimeType: item.mimeType,
|
});
|
||||||
createdAt: item.createdAt,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return searchResults;
|
return searchResults;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Media search failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||||
@@ -773,6 +860,16 @@ export class MediaEngine extends EventEmitter {
|
|||||||
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||||
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
|
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
|
||||||
|
|
||||||
|
// Delete all FTS entries for the current project
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (client) {
|
||||||
|
await client.execute({
|
||||||
|
sql: 'DELETE FROM media_fts WHERE project_id = ?',
|
||||||
|
args: [this.currentProjectId],
|
||||||
|
});
|
||||||
|
console.log(`Deleted media FTS entries for project ${this.currentProjectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
onProgress(5, 'Scanning media directory...');
|
onProgress(5, 'Scanning media directory...');
|
||||||
|
|
||||||
// Recursively find all .meta files in the media directory tree
|
// Recursively find all .meta files in the media directory tree
|
||||||
@@ -839,6 +936,16 @@ export class MediaEngine extends EventEmitter {
|
|||||||
tags: JSON.stringify(metadata.tags),
|
tags: JSON.stringify(metadata.tags),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
await this.updateFTSIndex({
|
||||||
|
id: metadata.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
originalName: metadata.originalName,
|
||||||
|
alt: metadata.alt,
|
||||||
|
caption: metadata.caption,
|
||||||
|
tags: metadata.tags,
|
||||||
|
});
|
||||||
|
|
||||||
// Insert post-media links based on linkedPostIds from sidecar
|
// Insert post-media links based on linkedPostIds from sidecar
|
||||||
const linkedPostIds = metadata.linkedPostIds || [];
|
const linkedPostIds = metadata.linkedPostIds || [];
|
||||||
for (let j = 0; j < linkedPostIds.length; j++) {
|
for (let j = 0; j < linkedPostIds.length; j++) {
|
||||||
@@ -960,6 +1067,72 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
return await taskManager.runTask(task);
|
return await taskManager.runTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reindex all text for full-text search.
|
||||||
|
* Runs as a background task with progress updates.
|
||||||
|
* Call this when search algorithms change or to fix search issues.
|
||||||
|
*/
|
||||||
|
async reindexText(): Promise<void> {
|
||||||
|
const task: Task<void> = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Reindex media search text',
|
||||||
|
execute: async (onProgress) => {
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Database client not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(0, 'Clearing existing media search index...');
|
||||||
|
|
||||||
|
// Clear the FTS entries for current project
|
||||||
|
await client.execute({
|
||||||
|
sql: 'DELETE FROM media_fts WHERE project_id = ?',
|
||||||
|
args: [this.currentProjectId],
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress(5, 'Loading media...');
|
||||||
|
|
||||||
|
const allMedia = await this.getAllMedia();
|
||||||
|
const total = allMedia.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
onProgress(100, 'No media to index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(10, `Indexing ${total} media items...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < allMedia.length; i++) {
|
||||||
|
const item = allMedia[i];
|
||||||
|
await this.updateFTSIndex({
|
||||||
|
id: item.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
|
originalName: item.originalName,
|
||||||
|
alt: item.alt,
|
||||||
|
caption: item.caption,
|
||||||
|
tags: item.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress (10% to 100%)
|
||||||
|
const progress = 10 + Math.round((i + 1) / total * 90);
|
||||||
|
if (i % 10 === 0 || i === allMedia.length - 1) {
|
||||||
|
onProgress(progress, `Indexed ${i + 1} of ${total} media items`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to event loop periodically
|
||||||
|
if (i % 20 === 0) {
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(100, `Reindexed ${total} media items`);
|
||||||
|
console.log(`Reindexed search text for ${total} media items`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskManager.runTask(task);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -376,6 +376,14 @@ export function registerIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('media:reindexText', async () => {
|
||||||
|
const engine = getMediaEngine();
|
||||||
|
// Fire and forget - don't await, let it run in background
|
||||||
|
engine.reindexText().catch(err => {
|
||||||
|
console.error('Media text reindex failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
|
safeHandle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
|
||||||
const engine = getMediaEngine();
|
const engine = getMediaEngine();
|
||||||
return engine.getThumbnailDataUrl(id, size || 'small');
|
return engine.getThumbnailDataUrl(id, size || 'small');
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getTags: () => ipcRenderer.invoke('media:getTags'),
|
getTags: () => ipcRenderer.invoke('media:getTags'),
|
||||||
getTagsWithCounts: () => ipcRenderer.invoke('media:getTagsWithCounts'),
|
getTagsWithCounts: () => ipcRenderer.invoke('media:getTagsWithCounts'),
|
||||||
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||||
|
reindexText: () => ipcRenderer.invoke('media:reindexText'),
|
||||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||||
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
||||||
|
|||||||
@@ -332,8 +332,9 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('menu:reindexText', () => {
|
window.electronAPI?.on('menu:reindexText', () => {
|
||||||
// Fire and forget - runs as a background task
|
// Fire and forget - runs as background tasks
|
||||||
window.electronAPI?.posts.reindexText();
|
window.electronAPI?.posts.reindexText();
|
||||||
|
window.electronAPI?.media.reindexText();
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
229
src/renderer/components/InsertModal/InsertModal.css
Normal file
229
src/renderer/components/InsertModal/InsertModal.css
Normal 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);
|
||||||
|
}
|
||||||
323
src/renderer/components/InsertModal/InsertModal.tsx
Normal file
323
src/renderer/components/InsertModal/InsertModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/InsertModal/index.ts
Normal file
1
src/renderer/components/InsertModal/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { InsertModal } from './InsertModal';
|
||||||
@@ -21,7 +21,7 @@ import { imageResolverPlugin } from '../../plugins/imageResolverPlugin';
|
|||||||
// 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';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { unescapeMacroSyntax } from '../../utils/markdownEscape';
|
import { unescapeMacroSyntax } from '../../utils/markdownEscape';
|
||||||
|
|
||||||
// Remark plugin to force tight lists (no blank lines between list items)
|
// Remark plugin to force tight lists (no blank lines between list items)
|
||||||
@@ -47,12 +47,7 @@ const remarkTightLists: RemarkPlugin = {
|
|||||||
options: {},
|
options: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SearchResult {
|
type InsertModalMode = 'link' | 'image' | null;
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
excerpt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MilkdownEditorProps {
|
interface MilkdownEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -63,7 +58,8 @@ 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);
|
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
|
||||||
// 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]);
|
}, [loading, getEditor]);
|
||||||
|
|
||||||
const insertLink = useCallback(() => {
|
// Get current selection text from editor
|
||||||
const url = window.prompt('Enter URL:');
|
const getSelectionText = useCallback(() => {
|
||||||
if (!url) return;
|
const editor = getEditor();
|
||||||
runCommand(toggleLinkCommand.key, { href: url });
|
if (!editor) return '';
|
||||||
}, [runCommand]);
|
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 openLinkModal = useCallback(() => {
|
||||||
const url = window.prompt('Enter image URL:');
|
const text = getSelectionText();
|
||||||
if (!url) return;
|
setSelectedText(text);
|
||||||
const alt = window.prompt('Enter alt text:', 'Image') || 'Image';
|
setInsertMode('link');
|
||||||
runCommand(insertImageCommand.key, { src: url, alt });
|
}, [getSelectionText]);
|
||||||
}, [runCommand]);
|
|
||||||
|
|
||||||
const insertPostLink = useCallback(() => {
|
const openImageModal = useCallback(() => {
|
||||||
setShowPostSearch(true);
|
setSelectedText('');
|
||||||
|
setInsertMode('image');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Add keyboard shortcut listener for Ctrl/Cmd+K
|
// Add keyboard shortcut listener for Ctrl/Cmd+K (link)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowPostSearch(true);
|
openLinkModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('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();
|
const editor = getEditor();
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
@@ -139,10 +146,10 @@ const EditorToolbar: React.FC = () => {
|
|||||||
const view = ctx.get(editorViewCtx);
|
const view = ctx.get(editorViewCtx);
|
||||||
const { state, dispatch } = view;
|
const { state, dispatch } = view;
|
||||||
const { selection, schema } = state;
|
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 linkText = currentSelectedText || text || url;
|
||||||
const linkUrl = `/posts/${post.slug}`;
|
const linkUrl = url;
|
||||||
|
|
||||||
if (selection.empty) {
|
if (selection.empty) {
|
||||||
// No selection - create text node with link mark and insert it
|
// No selection - create text node with link mark and insert it
|
||||||
@@ -158,9 +165,15 @@ const EditorToolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowPostSearch(false);
|
setInsertMode(null);
|
||||||
}, [getEditor]);
|
}, [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;
|
if (loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,9 +211,8 @@ const EditorToolbar: React.FC = () => {
|
|||||||
<div className="toolbar-divider" />
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button onClick={insertLink} title="Insert Link">🔗</button>
|
<button onClick={openLinkModal} title="Insert Link (Ctrl+K)">🔗</button>
|
||||||
<button onClick={insertPostLink} title="Link to Post (Ctrl+K)">📝</button>
|
<button onClick={openImageModal} title="Insert Image">🖼</button>
|
||||||
<button onClick={insertImage} title="Insert Image">🖼</button>
|
|
||||||
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule">―</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,10 +224,13 @@ const EditorToolbar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPostSearch && (
|
{insertMode && (
|
||||||
<PostSearchModal
|
<InsertModal
|
||||||
onSelect={handlePostSelected}
|
mode={insertMode}
|
||||||
onClose={() => setShowPostSearch(false)}
|
onInsertLink={handleInsertLink}
|
||||||
|
onInsertImage={handleInsertImage}
|
||||||
|
onClose={() => setInsertMode(null)}
|
||||||
|
initialText={selectedText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
|||||||
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
|
||||||
export { ChatPanel } from './ChatPanel';
|
export { ChatPanel } from './ChatPanel';
|
||||||
export { ImportAnalysisView } from './ImportAnalysisView';
|
export { ImportAnalysisView } from './ImportAnalysisView';
|
||||||
|
export { InsertModal } from './InsertModal';
|
||||||
|
|||||||
1
src/renderer/types/electron.d.ts
vendored
1
src/renderer/types/electron.d.ts
vendored
@@ -292,6 +292,7 @@ export interface ElectronAPI {
|
|||||||
getFilePath: (id: string) => Promise<string | null>;
|
getFilePath: (id: string) => Promise<string | null>;
|
||||||
getAll: () => Promise<MediaData[]>;
|
getAll: () => Promise<MediaData[]>;
|
||||||
rebuildFromFiles: () => Promise<void>;
|
rebuildFromFiles: () => Promise<void>;
|
||||||
|
reindexText: () => Promise<void>;
|
||||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>;
|
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>;
|
||||||
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
|
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
|
||||||
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
|
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
|
||||||
|
|||||||
@@ -92,11 +92,25 @@ function createDrizzleMock() {
|
|||||||
|
|
||||||
const mockLocalDb = createDrizzleMock();
|
const mockLocalDb = createDrizzleMock();
|
||||||
|
|
||||||
|
// Track FTS operations
|
||||||
|
let ftsExecuteCalls: { sql: string; args?: any[] }[] = [];
|
||||||
|
|
||||||
// Mock the database module
|
// Mock the database module
|
||||||
vi.mock('../../src/main/database', () => ({
|
vi.mock('../../src/main/database', () => ({
|
||||||
getDatabase: vi.fn(() => ({
|
getDatabase: vi.fn(() => ({
|
||||||
getLocal: vi.fn(() => mockLocalDb),
|
getLocal: vi.fn(() => mockLocalDb),
|
||||||
getLocalClient: vi.fn(() => null),
|
getLocalClient: vi.fn(() => ({
|
||||||
|
execute: vi.fn(async (query: { sql: string; args?: any[] } | string) => {
|
||||||
|
const sqlObj = typeof query === 'string' ? { sql: query } : query;
|
||||||
|
ftsExecuteCalls.push(sqlObj);
|
||||||
|
|
||||||
|
// Mock FTS search results
|
||||||
|
if (sqlObj.sql.includes('media_fts MATCH')) {
|
||||||
|
return { rows: [] }; // Return empty results by default
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
}),
|
||||||
|
})),
|
||||||
getRemote: vi.fn(() => null),
|
getRemote: vi.fn(() => null),
|
||||||
getDataPaths: vi.fn(() => ({
|
getDataPaths: vi.fn(() => ({
|
||||||
database: '/mock/userData/bds.db',
|
database: '/mock/userData/bds.db',
|
||||||
@@ -164,6 +178,7 @@ describe('MediaEngine', () => {
|
|||||||
mediaDeleteCalled = false;
|
mediaDeleteCalled = false;
|
||||||
postMediaDeleteCalled = false;
|
postMediaDeleteCalled = false;
|
||||||
postMediaInserts = [];
|
postMediaInserts = [];
|
||||||
|
ftsExecuteCalls = [];
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
|
|
||||||
// Reset the mock implementations
|
// Reset the mock implementations
|
||||||
@@ -185,6 +200,25 @@ describe('MediaEngine', () => {
|
|||||||
it('should have default project context', () => {
|
it('should have default project context', () => {
|
||||||
expect(mediaEngine.getProjectContext()).toBe('default');
|
expect(mediaEngine.getProjectContext()).toBe('default');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have default search language', () => {
|
||||||
|
expect(mediaEngine.getSearchLanguage()).toBe('english');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Language', () => {
|
||||||
|
it('should set search language', () => {
|
||||||
|
mediaEngine.setSearchLanguage('german');
|
||||||
|
expect(mediaEngine.getSearchLanguage()).toBe('german');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing search language multiple times', () => {
|
||||||
|
mediaEngine.setSearchLanguage('french');
|
||||||
|
expect(mediaEngine.getSearchLanguage()).toBe('french');
|
||||||
|
|
||||||
|
mediaEngine.setSearchLanguage('spanish');
|
||||||
|
expect(mediaEngine.getSearchLanguage()).toBe('spanish');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Project Context', () => {
|
describe('Project Context', () => {
|
||||||
@@ -680,4 +714,31 @@ linkedPostIds: ["post-a", "post-b", "post-c"]`;
|
|||||||
expect(postMediaInserts[2].sortOrder).toBe(2);
|
expect(postMediaInserts[2].sortOrder).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Full-Text Search', () => {
|
||||||
|
it('should search media using FTS with stemmed query', async () => {
|
||||||
|
// Search for media
|
||||||
|
await mediaEngine.searchMedia('beautiful sunset');
|
||||||
|
|
||||||
|
// Should have called the FTS search
|
||||||
|
const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH'));
|
||||||
|
expect(ftsSearchCall).toBeDefined();
|
||||||
|
expect(ftsSearchCall?.args?.[0]).toBe('default'); // project_id
|
||||||
|
// The query should be stemmed (e.g., 'beautiful sunset' -> 'beauti sunset')
|
||||||
|
expect(ftsSearchCall?.args?.[1]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter search by project_id', async () => {
|
||||||
|
mediaEngine.setProjectContext('my-project');
|
||||||
|
await mediaEngine.searchMedia('test');
|
||||||
|
|
||||||
|
const ftsSearchCall = ftsExecuteCalls.find(c => c.sql.includes('media_fts MATCH'));
|
||||||
|
expect(ftsSearchCall?.args?.[0]).toBe('my-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no results found', async () => {
|
||||||
|
const results = await mediaEngine.searchMedia('nonexistent');
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user