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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user