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 = '
No media linked to this post
';
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 = 'No images linked to this post
';
@@ -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
- setShowMediaPicker(false)}>×
+ { setShowMediaPicker(false); setMediaSearchQuery(''); }}>×
+
+
+ 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;