Add manual save button, improve save logic and feedback

- Added a manual "Save" button to the editor, enabled only when there are unsaved changes.
- Enhanced save status feedback with new states ("Modified", "Saving...", "Saved", "Error", "Timeout") and distinct styling.
- Improved auto-save: increased debounce to 2s, only show "Saving..." when actually saving, and unified manual/auto save logic.
- SaveContent now uses a 30s timeout and retries on error/timeout.
- SessionService now streams large content to disk in chunks and ensures the save directory exists.
- Updated CSS for save button and status indicators, including mobile tweaks.
- Removed unused exception variables in SharedEditor.razor and SharedPreview.razor.
- Updated markdown spec file to reflect new methods and build errors.
This commit is contained in:
Michal Vrba
2026-01-30 17:17:45 +01:00
parent 33b70585c1
commit 71edc08d5e
7 changed files with 151 additions and 42 deletions
+1
View File
@@ -362,3 +362,4 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/Notemark/data/shares /Notemark/data/shares
/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md
+57 -14
View File
@@ -24,6 +24,9 @@
</div> </div>
<div class="top-bar-right"> <div class="top-bar-right">
<button class="btn-save" @onclick="ManualSave" title="Save document (Ctrl+S)" disabled="@(!hasUnsavedChanges)">
💾
</button>
<div class="share-container"> <div class="share-container">
<button class="btn-share" @onclick="ToggleShareDropdown">Share</button> <button class="btn-share" @onclick="ToggleShareDropdown">Share</button>
@@ -128,7 +131,6 @@
{ {
markdownContent = e.Value?.ToString() ?? ""; markdownContent = e.Value?.ToString() ?? "";
hasUnsavedChanges = true; hasUnsavedChanges = true;
saveStatus = "Saving...";
// Update preview if visible // Update preview if visible
if (showPreview) if (showPreview)
@@ -136,25 +138,24 @@
renderedHtml = MarkdownService.RenderToHtml(markdownContent); renderedHtml = MarkdownService.RenderToHtml(markdownContent);
} }
// Debounce auto-save // Debounced autosave - cancel previous timer and start new one
autoSaveTimer?.Dispose(); autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ => await AutoSave(), null, 1000, Timeout.Infinite); autoSaveTimer = new Timer(async _ =>
{
await InvokeAsync(async () => await AutoSave());
}, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production
// Don't show "Saving..." immediately, wait for actual save
if (saveStatus == "Saved")
{
saveStatus = "Modified";
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async Task OnKeyDown(KeyboardEventArgs e)
{
// Ctrl+S or Cmd+S for manual save
if ((e.CtrlKey || e.MetaKey) && e.Key == "s")
{
await SaveContent();
}
} }
private async Task AutoSave() private async Task AutoSave()
{ {
if (hasUnsavedChanges) if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
{ {
await SaveContent(); await SaveContent();
} }
@@ -162,18 +163,46 @@
private async Task SaveContent() private async Task SaveContent()
{ {
if (string.IsNullOrEmpty(sessionId)) if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges)
return; return;
// Set saving status only when actually starting to save
saveStatus = "Saving...";
await InvokeAsync(StateHasChanged);
try try
{ {
// Longer timeout for large content (30 seconds)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await SessionService.SaveSessionContentAsync(sessionId, markdownContent); await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
hasUnsavedChanges = false; hasUnsavedChanges = false;
saveStatus = "Saved"; saveStatus = "Saved";
} }
catch catch (OperationCanceledException)
{
saveStatus = "Timeout";
// Retry after 5 seconds
_ = Task.Delay(5000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
catch (Exception)
{ {
saveStatus = "Error"; saveStatus = "Error";
// Retry after 3 seconds
_ = Task.Delay(3000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
} }
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -189,6 +218,20 @@
} }
} }
private async Task OnKeyDown(KeyboardEventArgs e)
{
// Ctrl+S or Cmd+S for manual save
if ((e.CtrlKey || e.MetaKey) && e.Key == "s")
{
await ManualSave();
}
}
private async Task ManualSave()
{
await SaveContent();
}
private async Task ToggleShareDropdown() private async Task ToggleShareDropdown()
{ {
if (!showShareDropdown) if (!showShareDropdown)
+45 -2
View File
@@ -35,6 +35,7 @@
/* Buttons */ /* Buttons */
.btn-toggle, .btn-toggle,
.btn-share, .btn-share,
.btn-save,
.btn-copy, .btn-copy,
.btn-close { .btn-close {
background: none; background: none;
@@ -48,6 +49,33 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn-save {
padding: 0.5rem;
min-width: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.btn-save:enabled:hover {
background-color: var(--success);
color: white;
border-color: var(--success);
transform: translateY(-1px);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: transparent;
}
.btn-save:disabled:hover {
background-color: transparent;
transform: none;
}
.btn-toggle:hover, .btn-toggle:hover,
.btn-share:hover, .btn-share:hover,
.btn-copy:hover { .btn-copy:hover {
@@ -87,14 +115,22 @@
color: var(--success); color: var(--success);
} }
.save-status.saving { .save-status.modified {
color: var(--secondary-text); color: var(--secondary-text);
} }
.save-status.saving {
color: var(--accent);
}
.save-status.error { .save-status.error {
color: var(--error); color: var(--error);
} }
.save-status.timeout {
color: var(--error);
}
/* Editor Layout */ /* Editor Layout */
.editor-layout { .editor-layout {
flex: 1; flex: 1;
@@ -396,11 +432,18 @@
} }
.btn-toggle, .btn-toggle,
.btn-share { .btn-share,
.btn-save {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
} }
.btn-save {
padding: 0.375rem;
min-width: 2rem;
font-size: 0.875rem;
}
.markdown-editor, .markdown-editor,
.preview-content { .preview-content {
padding: 1rem; padding: 1rem;
+1 -1
View File
@@ -141,7 +141,7 @@ else
renderedHtml = MarkdownService.RenderToHtml(markdownContent); renderedHtml = MarkdownService.RenderToHtml(markdownContent);
} }
} }
catch (Exception ex) catch (Exception)
{ {
errorMessage = "An error occurred while loading the document."; errorMessage = "An error occurred while loading the document.";
} }
@@ -113,7 +113,7 @@ else
// Render to HTML // Render to HTML
renderedHtml = MarkdownService.RenderToHtml(markdownContent); renderedHtml = MarkdownService.RenderToHtml(markdownContent);
} }
catch (Exception ex) catch (Exception)
{ {
errorMessage = "An error occurred while loading the document."; errorMessage = "An error occurred while loading the document.";
} }
+45 -1
View File
@@ -1,3 +1,5 @@
using System.Text;
namespace Notemark.Services; namespace Notemark.Services;
public class SessionService : ISessionService public class SessionService : ISessionService
@@ -50,7 +52,49 @@ public class SessionService : ISessionService
public async Task SaveSessionContentAsync(string sessionId, string content) public async Task SaveSessionContentAsync(string sessionId, string content)
{ {
var filePath = GetSessionFilePath(sessionId); var filePath = GetSessionFilePath(sessionId);
await File.WriteAllTextAsync(filePath, content ?? string.Empty);
// Create directory if it doesn't exist
var directory = Path.GetDirectoryName(filePath)!;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// For large content, use streaming to avoid memory issues
try
{
if (string.IsNullOrEmpty(content))
{
await File.WriteAllTextAsync(filePath, string.Empty, Encoding.UTF8);
return;
}
// Use FileStream for large content to avoid memory pressure
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
await using var writer = new StreamWriter(fileStream, Encoding.UTF8);
// Write in chunks for very large content
if (content.Length > 50000) // > 50KB
{
const int chunkSize = 8192;
for (int i = 0; i < content.Length; i += chunkSize)
{
int length = Math.Min(chunkSize, content.Length - i);
await writer.WriteAsync(content.AsMemory(i, length));
await writer.FlushAsync(); // Ensure data is written
}
}
else
{
await writer.WriteAsync(content);
}
}
catch (DirectoryNotFoundException)
{
// Retry after creating directory
Directory.CreateDirectory(directory);
await File.WriteAllTextAsync(filePath, content ?? string.Empty, Encoding.UTF8);
}
} }
public async Task<bool> SessionExistsAsync(string sessionId) public async Task<bool> SessionExistsAsync(string sessionId)
@@ -1,22 +0,0 @@
# Notemark — .NET 10 Blazor Server Specification (Markdown Editor + Preview + Share)
## 0) Goal
Notemark is a minimalist Markdown editor web app built with **.NET 10 Blazor Server**.
- First visit generates a **GUID sessionId**
- Markdown is saved as a **file on disk** (no database)
- Fullscreen editor, optional preview split view
- Share links:
- **Readonly** → preview only
- **Editable** → preview only + Edit button
---
## 1) Tech Stack
- .NET 10
- Blazor Server
- Markdown rendering: Markdig
- HTML sanitization: Ganss.XSS (HtmlSanitizer)
- Persistence: File system only