diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index fb91fa8..b258157 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -115,6 +115,32 @@ gap: 16px; } +.metadata-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 4px; + background: none; + border: none; + color: var(--vscode-descriptionForeground); + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: color 0.15s; +} + +.metadata-toggle:hover { + color: var(--vscode-foreground); +} + +.metadata-toggle-chevron { + font-size: 10px; + transition: transform 0.15s; +} + .editor-header-row { display: flex; gap: 16px; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index d24e50b..414a408 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -189,6 +189,7 @@ export const PostEditor: React.FC = ({ postId }) => { const [lightboxIndex, setLightboxIndex] = useState(0); const [showPostSearch, setShowPostSearch] = useState(false); const [showMediaSearch, setShowMediaSearch] = useState(false); + const [metadataExpanded, setMetadataExpanded] = useState(true); const editorRef = useRef(null); const isDirty = checkIsDirty(postId); @@ -298,6 +299,9 @@ export const PostEditor: React.FC = ({ postId }) => { setAuthor(post.author || ''); setTags(post.tags); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); + if (!isInitialized) { + setMetadataExpanded(post.title === ''); + } markClean(postId); // Mark as initialized AFTER setting local state setIsInitialized(true); @@ -718,6 +722,14 @@ export const PostEditor: React.FC = ({ postId }) => {
+ + {metadataExpanded && (
@@ -768,18 +780,19 @@ export const PostEditor: React.FC = ({ postId }) => { />
- + useAppStore.getState().setSelectedPost(id)} />
- +
+ )}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 45d9afd..447a5fb 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -510,6 +510,7 @@ "editor.insertMediaTitle": "Bild aus Medienbibliothek einfügen", "editor.previewFrameTitle": "Beitragsvorschau", "editor.previewLoading": "Vorschau wird geladen...", + "editor.metadata.toggle": "Metadaten", "editor.footer.created": "Erstellt", "editor.footer.updated": "Aktualisiert", "editor.footer.published": "Veröffentlicht", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 35b24c1..6401a46 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -510,6 +510,7 @@ "editor.insertMediaTitle": "Insert image from media library", "editor.previewFrameTitle": "Post preview", "editor.previewLoading": "Loading preview...", + "editor.metadata.toggle": "Metadata", "editor.footer.created": "Created", "editor.footer.updated": "Updated", "editor.footer.published": "Published", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 3afaad8..4458651 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -510,6 +510,7 @@ "editor.insertMediaTitle": "Insertar imagen desde la biblioteca multimedia", "editor.previewFrameTitle": "Vista previa de la entrada", "editor.previewLoading": "Cargando vista previa...", + "editor.metadata.toggle": "Metadatos", "editor.footer.created": "Creado", "editor.footer.updated": "Actualizado", "editor.footer.published": "Publicado", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 9638a0b..98431b4 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -507,7 +507,8 @@ "editor.insertPostLinkTitle": "Lier à un article (Ctrl+K)", "editor.insertMediaTitle": "Insérer une image depuis la bibliothèque média", "editor.previewFrameTitle": "Aperçu de l’article", - "editor.previewLoading": "Chargement de l’aperçu...", + "editor.previewLoading": "Chargement de l'aperçu...", + "editor.metadata.toggle": "Métadonnées", "editor.footer.created": "Créé", "editor.footer.updated": "Mis à jour", "editor.footer.published": "Publié", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index aba7a37..6abde03 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -508,6 +508,7 @@ "editor.insertMediaTitle": "Inserisci immagine dalla libreria media", "editor.previewFrameTitle": "Anteprima post", "editor.previewLoading": "Caricamento anteprima...", + "editor.metadata.toggle": "Metadati", "editor.footer.created": "Creato", "editor.footer.updated": "Aggiornato", "editor.footer.published": "Pubblicato", diff --git a/tests/renderer/components/EditorMetadataCollapse.test.tsx b/tests/renderer/components/EditorMetadataCollapse.test.tsx new file mode 100644 index 0000000..e04aa6f --- /dev/null +++ b/tests/renderer/components/EditorMetadataCollapse.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, act, fireEvent } from '@testing-library/react'; + +vi.mock('@monaco-editor/react', () => ({ + default: () =>
, +})); + +vi.mock('@milkdown/kit/core', () => { + const makeChain = () => { + const chain = { + config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => { + callback({ + set: () => {}, + get: () => ({ + markdownUpdated: () => {}, + }), + }); + return chain; + }, + use: () => chain, + }; + return chain; + }; + + return { + Editor: { make: makeChain }, + defaultValueCtx: Symbol('defaultValueCtx'), + editorViewCtx: Symbol('editorViewCtx'), + rootCtx: Symbol('rootCtx'), + remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'), + remarkPluginsCtx: Symbol('remarkPluginsCtx'), + }; +}); + +vi.mock('@milkdown/kit/preset/commonmark', () => ({ + commonmark: {}, + toggleStrongCommand: { key: 'toggleStrong' }, + toggleEmphasisCommand: { key: 'toggleEmphasis' }, + wrapInBlockquoteCommand: { key: 'wrapInBlockquote' }, + wrapInBulletListCommand: { key: 'wrapInBulletList' }, + wrapInOrderedListCommand: { key: 'wrapInOrderedList' }, + insertHrCommand: { key: 'insertHr' }, + toggleInlineCodeCommand: { key: 'toggleInlineCode' }, + insertImageCommand: { key: 'insertImage' }, + toggleLinkCommand: { key: 'toggleLink' }, +})); + +vi.mock('@milkdown/kit/preset/gfm', () => ({ + gfm: {}, + toggleStrikethroughCommand: { key: 'toggleStrike' }, +})); + +vi.mock('@milkdown/kit/plugin/history', () => ({ + history: {}, + undoCommand: { key: 'undo' }, + redoCommand: { key: 'redo' }, +})); + +vi.mock('@milkdown/kit/plugin/listener', () => ({ + listener: {}, + listenerCtx: Symbol('listenerCtx'), +})); + +vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} })); +vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} })); +vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} })); +vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} })); + +vi.mock('@milkdown/kit/utils', () => ({ + $node: () => ({}), + $inputRule: () => ({}), + $remark: () => ({}), + $prose: () => ({}), + replaceAll: () => () => {}, + callCommand: () => () => {}, +})); + +vi.mock('@milkdown/react', () => ({ + Milkdown: () =>
, + MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useInstance: () => [false, () => ({ action: (action: unknown) => { + if (typeof action === 'function') { + action({ get: () => ({}) }); + } + } })] as const, + useEditor: (factory: (root: Node) => unknown) => { + factory(document.createElement('div')); + }, +})); + +vi.mock('../../../src/renderer/components/Lightbox', () => ({ + Lightbox: () => null, + useMarkdownImages: () => [], +})); +vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null })); +vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null })); +vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null })); +vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null })); +vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null })); +vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null })); +vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null })); +vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null })); +vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null })); +vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null })); +vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null })); +vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null })); +vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({ + AISuggestionsModal: () => null, +})); +vi.mock('../../../src/renderer/components/Toast', () => ({ + showToast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +import { PostEditor } from '../../../src/renderer/components/Editor/Editor'; +import { useAppStore } from '../../../src/renderer/store'; + +const createPost = (overrides: Record = {}) => ({ + id: 'post-1', + title: 'Test Post', + content: 'Some content', + excerpt: '', + slug: 'test-post', + status: 'draft' as const, + tags: ['tag1'], + categories: ['article'], + featuredImage: null, + publishedAt: null, + createdAt: new Date('2026-02-16T12:00:00.000Z'), + updatedAt: new Date('2026-02-16T12:00:00.000Z'), + author: undefined, + metadata: {}, + seoTitle: undefined, + seoDescription: undefined, + canonicalUrl: undefined, + projectId: 'project-1', + filePath: 'posts/test-post.md', + ...overrides, +}); + +describe('Editor metadata collapse', () => { + beforeEach(() => { + vi.clearAllMocks(); + const neverSettles = new Promise(() => {}); + + (window as any).addEventListener = vi.fn(); + (window as any).removeEventListener = vi.fn(); + + (window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles); + (window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null); + (window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/preview'); + (window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles); + + useAppStore.setState({ + preferredEditorMode: 'wysiwyg', + posts: [], + media: [], + dirtyPosts: new Set(), + isLoading: false, + }); + }); + + it('collapses metadata for existing posts (non-empty title)', async () => { + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: 'Existing Post' })); + + const { container } = render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const headerRow = container.querySelector('.editor-header-row'); + expect(headerRow).toBeNull(); + + const toggle = container.querySelector('.metadata-toggle'); + expect(toggle).not.toBeNull(); + }); + + it('expands metadata for new posts (empty title)', async () => { + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' })); + + const { container } = render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const headerRow = container.querySelector('.editor-header-row'); + expect(headerRow).not.toBeNull(); + + const toggle = container.querySelector('.metadata-toggle'); + expect(toggle).not.toBeNull(); + expect(toggle?.classList.contains('expanded')).toBe(true); + }); + + it('toggles metadata visibility on click', async () => { + (window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: 'Existing Post' })); + + const { container } = render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Initially collapsed for existing post + expect(container.querySelector('.editor-header-row')).toBeNull(); + + const toggle = container.querySelector('.metadata-toggle')!; + + // Click to expand + await act(async () => { + fireEvent.click(toggle); + }); + expect(container.querySelector('.editor-header-row')).not.toBeNull(); + + // Click to collapse again + await act(async () => { + fireEvent.click(toggle); + }); + expect(container.querySelector('.editor-header-row')).toBeNull(); + }); +});