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
FodyWeavers.xsd
/Notemark/data/shares
/Notemark/data/sessions/b5acffbe-706e-405e-b006-3415508a59a7.md
+58 -15
View File
@@ -24,6 +24,9 @@
</div>
<div class="top-bar-right">
<button class="btn-save" @onclick="ManualSave" title="Save document (Ctrl+S)" disabled="@(!hasUnsavedChanges)">
💾
</button>
<div class="share-container">
<button class="btn-share" @onclick="ToggleShareDropdown">Share</button>
@@ -128,7 +131,6 @@
{
markdownContent = e.Value?.ToString() ?? "";
hasUnsavedChanges = true;
saveStatus = "Saving...";
// Update preview if visible
if (showPreview)
@@ -136,25 +138,24 @@
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
// Debounce auto-save
// Debounced autosave - cancel previous timer and start new one
autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ => await AutoSave(), null, 1000, Timeout.Infinite);
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")
autoSaveTimer = new Timer(async _ =>
{
await SaveContent();
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);
}
}
private async Task AutoSave()
{
if (hasUnsavedChanges)
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
{
await SaveContent();
}
@@ -162,18 +163,46 @@
private async Task SaveContent()
{
if (string.IsNullOrEmpty(sessionId))
if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges)
return;
// Set saving status only when actually starting to save
saveStatus = "Saving...";
await InvokeAsync(StateHasChanged);
try
{
// Longer timeout for large content (30 seconds)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
hasUnsavedChanges = false;
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";
// Retry after 3 seconds
_ = Task.Delay(3000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
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()
{
if (!showShareDropdown)
+45 -2
View File
@@ -35,6 +35,7 @@
/* Buttons */
.btn-toggle,
.btn-share,
.btn-save,
.btn-copy,
.btn-close {
background: none;
@@ -48,6 +49,33 @@
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-share:hover,
.btn-copy:hover {
@@ -87,14 +115,22 @@
color: var(--success);
}
.save-status.saving {
.save-status.modified {
color: var(--secondary-text);
}
.save-status.saving {
color: var(--accent);
}
.save-status.error {
color: var(--error);
}
.save-status.timeout {
color: var(--error);
}
/* Editor Layout */
.editor-layout {
flex: 1;
@@ -396,11 +432,18 @@
}
.btn-toggle,
.btn-share {
.btn-share,
.btn-save {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
}
.btn-save {
padding: 0.375rem;
min-width: 2rem;
font-size: 0.875rem;
}
.markdown-editor,
.preview-content {
padding: 1rem;
+1 -1
View File
@@ -141,7 +141,7 @@ else
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
}
catch (Exception ex)
catch (Exception)
{
errorMessage = "An error occurred while loading the document.";
}
@@ -113,7 +113,7 @@ else
// Render to HTML
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
catch (Exception ex)
catch (Exception)
{
errorMessage = "An error occurred while loading the document.";
}
+45 -1
View File
@@ -1,3 +1,5 @@
using System.Text;
namespace Notemark.Services;
public class SessionService : ISessionService
@@ -50,7 +52,49 @@ public class SessionService : ISessionService
public async Task SaveSessionContentAsync(string sessionId, string content)
{
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)
@@ -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