feat: better gallery support
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user