diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bab4b49..6935b47 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -200,6 +200,13 @@ let historySelected = $state(null); let historyLoading = $state(false); + // Info panel + let showInfo = $state(false); + let infoPanelEl = $state(null); + let infoToggleBtnEl = $state(null); + let wordCount = $state(0); + let charCount = $state(0); + // In-note search let noteSearchOpen = $state(false); let noteSearchQuery = $state(''); @@ -2609,6 +2616,41 @@ }); }); + function countWords(text: string): number { + return text.trim() ? text.trim().split(/\s+/).filter(Boolean).length : 0; + } + + function updateCounts() { + let text = ''; + if (get(sourceMode)) { + text = sourceContent + .replace(/^---[\s\S]*?---\n?/, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/[*_~`]+/g, '') + .replace(/^>\s*/gm, '') + .replace(/^[-*+]\s+/gm, ''); + } else if (editor) { + text = editor.state.doc.textContent; + } + wordCount = countWords(text); + charCount = text.replace(/\s/g, '').length; + } + + async function toggleInfo() { + if (!showInfo && $activeNote) { + historyLoading = true; + try { + historyVersions = await getNoteVersions($activeNote.meta.id); + } catch { + historyVersions = []; + } + historyLoading = false; + } + showInfo = !showInfo; + } + async function toggleHistory() { showHistory = !showHistory; historyPreview = null; @@ -2756,6 +2798,7 @@ resetSourceHistory(sourceContent); if (editorBody) editorBody.scrollTop = 0; isLoadingNote = false; + updateCounts(); } else if (editorElement && editor) { // Editor already exists, just swap content const html = markdownToHtml(content); @@ -2763,6 +2806,9 @@ editor.commands.setContent(html); // Clear undo/redo history so it doesn't bleed across notes clearEditorHistory(); + const text = editor.state.doc.textContent; + wordCount = countWords(text); + charCount = text.replace(/\s/g, '').length; // Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle tick().then(() => { if (editorBody) editorBody.scrollTop = 0; @@ -3728,6 +3774,11 @@ if (pendingContent !== null) { createEditor(pendingContent); pendingContent = null; + if (editor) { + const text = (editor as any).state.doc.textContent; + wordCount = countWords(text); + charCount = text.replace(/\s/g, '').length; + } } else if (isMobile && !lastSourceMode) { createEditor(''); } @@ -3747,6 +3798,26 @@ return () => document.removeEventListener('mousedown', onClickAway); }); + // Live word/char count in source mode + $effect(() => { + if ($sourceMode) updateCounts(); + }); + + // Auto-close info panel on click outside + $effect(() => { + if (!showInfo) return; + function onInfoClickAway(e: MouseEvent) { + if ( + infoPanelEl && !infoPanelEl.contains(e.target as Node) && + infoToggleBtnEl && !infoToggleBtnEl.contains(e.target as Node) + ) { + showInfo = false; + } + } + document.addEventListener('mousedown', onInfoClickAway); + return () => document.removeEventListener('mousedown', onInfoClickAway); + }); + // Close in-note search when switching notes let prevSearchPath = ''; $effect(() => { @@ -4078,6 +4149,11 @@ $editorDirty = true; autoSave(); if (!isMobile && showOutline) updateOutline(); + if (editor) { + const text = editor.state.doc.textContent; + wordCount = countWords(text); + charCount = text.replace(/\s/g, '').length; + } }, }); editorReady = true; @@ -5418,6 +5494,19 @@ + {#if $appConfig?.enable_wiki_links} + + +
+ +
+ Words + {wordCount.toLocaleString()} +
+
+ Characters + {charCount.toLocaleString()} +
+
+ Reading time + {wordCount < 200 ? '< 1 min' : `${Math.ceil(wordCount / 200)} min`} +
+
+ +
+ +
+ Modified + {formatVersionDate($activeNote.meta.modified)} +
+
+ Created + {formatVersionDate($activeNote.meta.created)} +
+ {#if noteFolder} +
+ Location + {noteFolder} +
+ {/if} + {#if $activeNote.meta.tags?.length > 0} +
+ Tags + + {#each $activeNote.meta.tags as tag} + #{tag} + {/each} + +
+ {/if} +
+ +
+
+ + +
+ {#if historyLoading} +
Loading...
+ {:else if historyVersions.length === 0} +
No snapshots yet. They're created automatically as you edit.
+ {:else} +
+ {#each historyVersions as v} + + {/each} +
+ {/if} +
+ + {/if} @@ -10590,4 +10765,198 @@ padding: 12px 16px; font-size: 14px; } + + /* ═══ Info Panel ═══ */ + .info-panel { + width: 260px; + border-left: 1px solid var(--border-light); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + overflow-y: auto; + flex-shrink: 0; + } + + .info-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; + } + + .info-panel-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + } + + .info-close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: flex; + align-items: center; + } + + .info-close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .info-section { + padding: 12px 14px; + border-bottom: 1px solid var(--border-light); + } + + .info-section:last-child { + border-bottom: none; + flex: 1; + } + + .info-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .info-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); + margin-bottom: 8px; + } + + .info-section-header .info-section-label { + margin-bottom: 0; + } + + .info-snapshot-btn { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + padding: 3px 7px; + font-size: 11px; + } + + .info-snapshot-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .info-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 3px 0; + } + + .info-row-tags { + align-items: flex-start; + } + + .info-key { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + } + + .info-value { + font-size: 12px; + color: var(--text-primary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .info-value-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; + } + + .info-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; + } + + .info-tag { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border-radius: 3px; + padding: 1px 5px; + } + + .info-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + padding: 4px 0; + } + + .info-versions-list { + display: flex; + flex-direction: column; + gap: 1px; + margin-top: 4px; + } + + .info-version-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: 5px; + background: none; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + } + + .info-version-item:hover { + background: var(--bg-hover); + } + + .info-version-item.active { + background: var(--bg-active); + } + + .info-version-date { + font-size: 12px; + color: var(--text-primary); + } + + .info-version-size { + font-size: 11px; + color: var(--text-tertiary); + } + + .editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body { + display: none; + } + + .editor-container.mobile .info-panel { + width: 100%; + border-left: none; + border-top: 1px solid var(--border-light); + }