diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 621794c..3e74086 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import MonacoEditor from '@monaco-editor/react'; +import MonacoEditor, { Monaco } from '@monaco-editor/react'; import { useAppStore, PostData, EditorMode, MediaData } from '../../store'; import { showToast } from '../Toast'; import { MilkdownEditor } from '../MilkdownEditor'; @@ -183,15 +183,15 @@ const hydrateGalleries = async ( try { // Load linked media for this post - const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); + const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); - if (!mediaData || mediaData.length === 0) { + if (!linkedData || linkedData.length === 0) { galleryContainer.innerHTML = ''; continue; } - // Filter to images only - const images = mediaData.filter(m => m.mimeType?.startsWith('image/')); + // Filter to images only (media is nested in the link object) + const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/')); if (images.length === 0) { galleryContainer.innerHTML = ''; @@ -199,21 +199,21 @@ const hydrateGalleries = async ( } // Build gallery grid (column count is handled via CSS class on parent) - galleryContainer.innerHTML = images.map((media, index) => ` + galleryContainer.innerHTML = images.map((link, index) => ` `).join(''); // Set up lightbox click handlers const items = galleryContainer.querySelectorAll('.gallery-item'); - const imageData = images.map(m => ({ - src: `bds-media://${m.id}`, - alt: m.alt || m.originalName, + const imageData = images.map(link => ({ + src: `bds-media://${link.media.id}`, + alt: link.media.alt || link.media.originalName, })); items.forEach((item, index) => { @@ -526,6 +526,75 @@ const PostEditor: React.FC = ({ post }) => { editorRef.current = editor; }; + // Configure Monaco before mount to add macro syntax highlighting + const handleEditorWillMount = (monaco: Monaco) => { + // Register a custom language that extends markdown with macro support + monaco.languages.register({ id: 'markdown-with-macros' }); + + // Define custom tokenization that highlights [[macro]] syntax + monaco.languages.setMonarchTokensProvider('markdown-with-macros', { + defaultToken: '', + tokenPostfix: '.md', + + // Macros are the key addition + macroOpen: /\[\[/, + macroClose: /\]\]/, + + tokenizer: { + root: [ + // Macro syntax: [[macroName param="value"]] + [/\[\[[a-zA-Z][\w-]*/, { token: 'keyword.macro', next: '@macroParams' }], + + // Headers + [/^(\s{0,3})(#+)((?:[^\\#]|@escapes)+)((?:#+)?)/, ['white', 'keyword.header', 'variable', 'keyword.header']], + + // Block elements + [/^\s*>+/, 'string.quote'], + [/^\s*[\-+*]\s/, 'keyword'], + [/^\s*\d+\.\s/, 'keyword'], + [/^\s*```\w*/, { token: 'string.code', next: '@codeblock' }], + + // Inline elements + [/\*\*[^*]+\*\*/, 'strong'], + [/\*[^*]+\*/, 'emphasis'], + [/__[^_]+__/, 'strong'], + [/_[^_]+_/, 'emphasis'], + [/`[^`]+`/, 'variable'], + + // Links and images + [/!?\[[^\]]*\]\([^)]*\)/, 'string.link'], + [/!?\[[^\]]*\]\[[^\]]*\]/, 'string.link'], + ], + + macroParams: [ + [/\]\]/, { token: 'keyword.macro', next: '@root' }], + [/[a-zA-Z][\w-]*(?=\s*=)/, 'attribute.name'], + [/=/, 'delimiter'], + [/"[^"]*"/, 'string'], + [/\s+/, 'white'], + [/[^\]"=\s]+/, 'attribute.value'], + ], + + codeblock: [ + [/^\s*```\s*$/, { token: 'string.code', next: '@root' }], + [/.*$/, 'variable.source'], + ], + }, + }); + + // Define theme colors for macros + monaco.editor.defineTheme('vs-dark-macros', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'keyword.macro', foreground: 'C586C0', fontStyle: 'bold' }, + { token: 'attribute.name', foreground: '9CDCFE' }, + { token: 'attribute.value', foreground: 'CE9178' }, + ], + colors: {}, + }); + }; + // Save on Ctrl+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -688,11 +757,12 @@ const PostEditor: React.FC = ({ post }) => { {editorMode === 'markdown' && ( setContent(value || '')} onMount={handleEditorDidMount} - theme="vs-dark" + beforeMount={handleEditorWillMount} + theme="vs-dark-macros" options={{ minimap: { enabled: false }, wordWrap: 'on', diff --git a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css index 503cb21..111d6a5 100644 --- a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css +++ b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css @@ -228,6 +228,30 @@ color: var(--color-text-primary, #ccc); } +.media-picker-search { + padding: 8px 12px; + border-bottom: 1px solid var(--color-border, #3c3c3c); +} + +.media-picker-search input { + width: 100%; + padding: 6px 10px; + background: var(--color-bg-secondary, #252526); + border: 1px solid var(--color-border, #3c3c3c); + border-radius: 4px; + color: var(--color-text-primary, #ccc); + font-size: 12px; +} + +.media-picker-search input::placeholder { + color: var(--color-text-secondary, #8b8b8b); +} + +.media-picker-search input:focus { + outline: none; + border-color: var(--color-accent, #007acc); +} + .media-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); diff --git a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx index 6cbf14e..12d04ec 100644 --- a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx +++ b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx @@ -29,6 +29,7 @@ export const LinkedMediaPanel: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [dragOverIndex, setDragOverIndex] = useState(null); const [showMediaPicker, setShowMediaPicker] = useState(false); + const [mediaSearchQuery, setMediaSearchQuery] = useState(''); const { media: allMedia } = useAppStore(); // Load linked media for this post @@ -37,8 +38,9 @@ export const LinkedMediaPanel: React.FC = ({ try { setIsLoading(true); - const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); - setLinkedMedia(mediaData || []); + const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); + // Extract media objects from link data + setLinkedMedia((linkedData || []).map(link => link.media)); } catch (error) { console.error('Failed to load linked media:', error); } finally { @@ -90,6 +92,7 @@ export const LinkedMediaPanel: React.FC = ({ await window.electronAPI?.postMedia.link(postId, mediaId); showToast.success('Media linked to post'); setShowMediaPicker(false); + setMediaSearchQuery(''); loadLinkedMedia(); } catch (error) { console.error('Failed to link media:', error); @@ -149,9 +152,11 @@ export const LinkedMediaPanel: React.FC = ({ return null; }; - // Get unlinked media (for picker) + // Get unlinked media (for picker), filtered by search const unlinkedMedia = allMedia.filter( m => !linkedMedia.find(l => l.id === m.id) + ).filter( + m => !mediaSearchQuery || m.originalName.toLowerCase().includes(mediaSearchQuery.toLowerCase()) ); if (collapsed) { @@ -194,7 +199,16 @@ export const LinkedMediaPanel: React.FC = ({
Select media to link - + +
+
+ setMediaSearchQuery(e.target.value)} + autoFocus + />
{unlinkedMedia.length === 0 ? ( diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index eead493..3a4a1e4 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -21,6 +21,15 @@ import { macroPlugin } from '../../plugins/macroPlugin'; import '../../macros'; import './MilkdownEditor.css'; +/** + * Unescape brackets that Milkdown/remark escapes. + * This preserves macro syntax like [[gallery]] instead of \[\[gallery\]\] + */ +const unescapeBrackets = (markdown: string): string => { + // Unescape \[ and \] back to [ and ] + return markdown.replace(/\\\[/g, '[').replace(/\\\]/g, ']'); +}; + // Remark plugin to force tight lists (no blank lines between list items) const remarkTightListsPlugin: Plugin<[Record], Root> = () => { return (tree: Root) => { @@ -201,19 +210,23 @@ const MilkdownProviderInner: React.FC = ({ // Add custom remark plugin to force tight lists ctx.set(remarkPluginsCtx, [remarkTightLists]); ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => { - if (markdown !== prevMarkdown) { + // Unescape brackets to preserve macro syntax like [[gallery]] + const unescaped = unescapeBrackets(markdown); + const prevUnescaped = unescapeBrackets(prevMarkdown); + + if (unescaped !== prevUnescaped) { // On first update after load, store the normalized baseline // This captures Milkdown's round-trip formatting if (normalizedBaseline.current === null) { - normalizedBaseline.current = markdown; + normalizedBaseline.current = unescaped; return; // Don't trigger onChange for initial normalization } // Only trigger onChange if content differs from the baseline // (meaning the user actually edited something) - if (markdown !== normalizedBaseline.current) { + if (unescaped !== normalizedBaseline.current) { isInternalChange.current = true; - normalizedBaseline.current = markdown; // Update baseline - onChangeRef.current(markdown); + normalizedBaseline.current = unescaped; // Update baseline + onChangeRef.current(unescaped); } } }); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 3d608d5..5cf0e0b 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -297,7 +297,7 @@ export interface ElectronAPI { unlink: (postId: string, mediaId: string) => Promise; getForPost: (postId: string) => Promise; getForMedia: (mediaId: string) => Promise; - getMediaDataForPost: (postId: string) => Promise; + getMediaDataForPost: (postId: string) => Promise>; reorder: (postId: string, mediaIds: string[]) => Promise; isLinked: (postId: string, mediaId: string) => Promise; import: (postId: string, filePath: string) => Promise;