feat: better gallery support

This commit is contained in:
2026-02-12 17:02:57 +01:00
parent 924a165fb3
commit bdd21fb23f
5 changed files with 145 additions and 24 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; 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 { useAppStore, PostData, EditorMode, MediaData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import { MilkdownEditor } from '../MilkdownEditor'; import { MilkdownEditor } from '../MilkdownEditor';
@@ -183,15 +183,15 @@ const hydrateGalleries = async (
try { try {
// Load linked media for this post // 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 = '<div class="gallery-empty">No media linked to this post</div>'; galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
continue; continue;
} }
// Filter to images only // Filter to images only (media is nested in the link object)
const images = mediaData.filter(m => m.mimeType?.startsWith('image/')); const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/'));
if (images.length === 0) { if (images.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>'; galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>';
@@ -199,21 +199,21 @@ const hydrateGalleries = async (
} }
// Build gallery grid (column count is handled via CSS class on parent) // Build gallery grid (column count is handled via CSS class on parent)
galleryContainer.innerHTML = images.map((media, index) => ` galleryContainer.innerHTML = images.map((link, index) => `
<div class="gallery-item" data-index="${index}"> <div class="gallery-item" data-index="${index}">
<img <img
src="bds-media://${media.id}" src="bds-media://${link.media.id}"
alt="${media.alt || media.originalName}" alt="${link.media.alt || link.media.originalName}"
title="${media.originalName}" title="${link.media.originalName}"
/> />
</div> </div>
`).join(''); `).join('');
// Set up lightbox click handlers // Set up lightbox click handlers
const items = galleryContainer.querySelectorAll('.gallery-item'); const items = galleryContainer.querySelectorAll('.gallery-item');
const imageData = images.map(m => ({ const imageData = images.map(link => ({
src: `bds-media://${m.id}`, src: `bds-media://${link.media.id}`,
alt: m.alt || m.originalName, alt: link.media.alt || link.media.originalName,
})); }));
items.forEach((item, index) => { items.forEach((item, index) => {
@@ -526,6 +526,75 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
editorRef.current = editor; 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 // Save on Ctrl+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -688,11 +757,12 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
{editorMode === 'markdown' && ( {editorMode === 'markdown' && (
<MonacoEditor <MonacoEditor
height="100%" height="100%"
defaultLanguage="markdown" language="markdown-with-macros"
value={content} value={content}
onChange={(value) => setContent(value || '')} onChange={(value) => setContent(value || '')}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}
theme="vs-dark" beforeMount={handleEditorWillMount}
theme="vs-dark-macros"
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
wordWrap: 'on', wordWrap: 'on',

View File

@@ -228,6 +228,30 @@
color: var(--color-text-primary, #ccc); 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 { .media-picker-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));

View File

@@ -29,6 +29,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [showMediaPicker, setShowMediaPicker] = useState(false); const [showMediaPicker, setShowMediaPicker] = useState(false);
const [mediaSearchQuery, setMediaSearchQuery] = useState('');
const { media: allMedia } = useAppStore(); const { media: allMedia } = useAppStore();
// Load linked media for this post // Load linked media for this post
@@ -37,8 +38,9 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
try { try {
setIsLoading(true); setIsLoading(true);
const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId); const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
setLinkedMedia(mediaData || []); // Extract media objects from link data
setLinkedMedia((linkedData || []).map(link => link.media));
} catch (error) { } catch (error) {
console.error('Failed to load linked media:', error); console.error('Failed to load linked media:', error);
} finally { } finally {
@@ -90,6 +92,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
await window.electronAPI?.postMedia.link(postId, mediaId); await window.electronAPI?.postMedia.link(postId, mediaId);
showToast.success('Media linked to post'); showToast.success('Media linked to post');
setShowMediaPicker(false); setShowMediaPicker(false);
setMediaSearchQuery('');
loadLinkedMedia(); loadLinkedMedia();
} catch (error) { } catch (error) {
console.error('Failed to link media:', error); console.error('Failed to link media:', error);
@@ -149,9 +152,11 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
return null; return null;
}; };
// Get unlinked media (for picker) // Get unlinked media (for picker), filtered by search
const unlinkedMedia = allMedia.filter( const unlinkedMedia = allMedia.filter(
m => !linkedMedia.find(l => l.id === m.id) m => !linkedMedia.find(l => l.id === m.id)
).filter(
m => !mediaSearchQuery || m.originalName.toLowerCase().includes(mediaSearchQuery.toLowerCase())
); );
if (collapsed) { if (collapsed) {
@@ -194,7 +199,16 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
<div className="media-picker"> <div className="media-picker">
<div className="media-picker-header"> <div className="media-picker-header">
<span>Select media to link</span> <span>Select media to link</span>
<button onClick={() => setShowMediaPicker(false)}>×</button> <button onClick={() => { setShowMediaPicker(false); setMediaSearchQuery(''); }}>×</button>
</div>
<div className="media-picker-search">
<input
type="text"
placeholder="Search media..."
value={mediaSearchQuery}
onChange={(e) => setMediaSearchQuery(e.target.value)}
autoFocus
/>
</div> </div>
<div className="media-picker-grid"> <div className="media-picker-grid">
{unlinkedMedia.length === 0 ? ( {unlinkedMedia.length === 0 ? (

View File

@@ -21,6 +21,15 @@ import { macroPlugin } from '../../plugins/macroPlugin';
import '../../macros'; import '../../macros';
import './MilkdownEditor.css'; 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) // Remark plugin to force tight lists (no blank lines between list items)
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => { const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
return (tree: Root) => { return (tree: Root) => {
@@ -201,19 +210,23 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
// Add custom remark plugin to force tight lists // Add custom remark plugin to force tight lists
ctx.set(remarkPluginsCtx, [remarkTightLists]); ctx.set(remarkPluginsCtx, [remarkTightLists]);
ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => { 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 // On first update after load, store the normalized baseline
// This captures Milkdown's round-trip formatting // This captures Milkdown's round-trip formatting
if (normalizedBaseline.current === null) { if (normalizedBaseline.current === null) {
normalizedBaseline.current = markdown; normalizedBaseline.current = unescaped;
return; // Don't trigger onChange for initial normalization return; // Don't trigger onChange for initial normalization
} }
// Only trigger onChange if content differs from the baseline // Only trigger onChange if content differs from the baseline
// (meaning the user actually edited something) // (meaning the user actually edited something)
if (markdown !== normalizedBaseline.current) { if (unescaped !== normalizedBaseline.current) {
isInternalChange.current = true; isInternalChange.current = true;
normalizedBaseline.current = markdown; // Update baseline normalizedBaseline.current = unescaped; // Update baseline
onChangeRef.current(markdown); onChangeRef.current(unescaped);
} }
} }
}); });

View File

@@ -297,7 +297,7 @@ export interface ElectronAPI {
unlink: (postId: string, mediaId: string) => Promise<void>; unlink: (postId: string, mediaId: string) => Promise<void>;
getForPost: (postId: string) => Promise<MediaLinkData[]>; getForPost: (postId: string) => Promise<MediaLinkData[]>;
getForMedia: (mediaId: string) => Promise<MediaLinkData[]>; getForMedia: (mediaId: string) => Promise<MediaLinkData[]>;
getMediaDataForPost: (postId: string) => Promise<MediaData[]>; getMediaDataForPost: (postId: string) => Promise<Array<MediaLinkData & { media: MediaData }>>;
reorder: (postId: string, mediaIds: string[]) => Promise<void>; reorder: (postId: string, mediaIds: string[]) => Promise<void>;
isLinked: (postId: string, mediaId: string) => Promise<boolean>; isLinked: (postId: string, mediaId: string) => Promise<boolean>;
import: (postId: string, filePath: string) => Promise<MediaLinkData>; import: (postId: string, filePath: string) => Promise<MediaLinkData>;