Files
Notemark/Notemark/Components/Pages/Editor.razor
T
Michal Vrba efc31f643d Improve splitter UX: visual feedback & smoother resizing
Enhanced the editor's vertical splitter with immediate visual feedback during dragging, including dynamic width, shadow, and handle size. Updated logic to re-render UI and save split ratio in real time. Refined save status updates to reduce unnecessary renders. Improved mobile responsiveness and overall user experience.
2026-01-31 13:23:29 +01:00

434 lines
14 KiB
Plaintext

@page "/"
@rendermode InteractiveServer
@using Notemark.Services
@inject ISessionService SessionService
@inject IMarkdownService MarkdownService
@inject IShareService ShareService
@inject IJSRuntime JSRuntime
@inject IHttpContextAccessor HttpContextAccessor
<PageTitle>Notemark</PageTitle>
<div class="editor-container">
<!-- Top Bar -->
<div class="top-bar">
<div class="top-bar-left">
<button class="btn-new" @onclick="NewDocument" title="Create new document (Ctrl+N)">
📄 New
</button>
<button class="btn-toggle @(showPreview ? "active" : "")"
@onclick="TogglePreview">
👁️ @(showPreview ? "Hide Preview" : "Show Preview")
</button>
</div>
<div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
</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>
@if (showShareDropdown)
{
<div class="dropdown-backdrop" @onclick="CloseShareDropdown"></div>
<div class="share-dropdown">
@if (isGeneratingLinks)
{
<div class="loading-small">Generating...</div>
}
else if (!string.IsNullOrEmpty(readonlyLink))
{
<div class="share-item">
<label>View Only</label>
<div class="link-group">
<input type="text" readonly value="@readonlyLink" class="link-field" />
<button @onclick="() => CopyToClipboard(readonlyLink)" class="btn-copy-small">📋</button>
</div>
</div>
<div class="share-item">
<label>Can Edit</label>
<div class="link-group">
<input type="text" readonly value="@editableLink" class="link-field" />
<button @onclick="() => CopyToClipboard(editableLink)" class="btn-copy-small">📋</button>
</div>
</div>
}
</div>
}
</div>
<ThemeToggle />
</div>
</div>
<!-- Editor Layout -->
<div class="editor-layout @(showPreview ? "split-view" : "full-view")" @ref="editorLayoutRef">
<!-- Editor Pane -->
<div class="editor-pane" style="@GetEditorPaneStyle()">
<textarea class="markdown-editor"
@bind="markdownContent"
@oninput="OnContentChanged"
@onkeydown="OnKeyDown"
placeholder="Start typing your markdown..."
spellcheck="true">
</textarea>
</div>
<!-- Simple Splitter -->
@if (showPreview)
{
<div class="splitter @(isDragging ? "dragging" : "")" @onmousedown="OnSplitterMouseDown" @onmousedown:preventDefault="true">
<div class="splitter-handle"></div>
</div>
}
<!-- Preview Pane -->
@if (showPreview)
{
<div class="preview-pane" style="@GetPreviewPaneStyle()">
<div class="preview-content">
@((MarkupString)renderedHtml)
</div>
</div>
}
</div>
<!-- Share Modal -->
@* Removed modal - now using dropdown *@
</div>
@code {
private string sessionId = "";
private string markdownContent = "";
private string renderedHtml = "";
private bool showPreview = false;
private string saveStatus = "Saved";
// Share dropdown state
private bool showShareDropdown = false;
private bool isGeneratingLinks = false;
private string readonlyLink = "";
private string editableLink = "";
// Auto-save timer
private Timer? autoSaveTimer;
private bool hasUnsavedChanges = false;
protected override async Task OnInitializedAsync()
{
// Get or create session
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
sessionId = SessionService.GetOrCreateSessionId(httpContext);
// Load existing content
markdownContent = await SessionService.LoadSessionContentAsync(sessionId);
// Render initial preview if needed
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
}
}
private async Task OnContentChanged(ChangeEventArgs e)
{
markdownContent = e.Value?.ToString() ?? "";
hasUnsavedChanges = true;
// Update preview if visible
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
// Debounced autosave - cancel previous timer and start new one
autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ =>
{
await InvokeAsync(async () => await AutoSave());
}, null, 2000, Timeout.Infinite); // Increased to 2 seconds for production
// Only update status if it was previously "Saved"
if (saveStatus == "Saved")
{
saveStatus = "Modified";
StateHasChanged();
}
}
private async Task AutoSave()
{
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
{
await SaveContent();
}
}
private async Task SaveContent()
{
if (string.IsNullOrEmpty(sessionId) || !hasUnsavedChanges)
return;
var previousStatus = saveStatus;
// Set saving status only when actually starting to save
saveStatus = "Saving...";
StateHasChanged(); // Use StateHasChanged instead of InvokeAsync
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 (OperationCanceledException)
{
saveStatus = "Timeout";
// Simplified retry - no immediate re-rendering
_ = Task.Delay(5000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
catch (Exception)
{
saveStatus = "Error";
// Simplified retry - no immediate re-rendering
_ = Task.Delay(3000).ContinueWith(async _ =>
{
if (hasUnsavedChanges)
{
await InvokeAsync(async () => await SaveContent());
}
});
}
// Only trigger re-render if status actually changed
if (saveStatus != previousStatus)
{
StateHasChanged();
}
}
private void TogglePreview()
{
showPreview = !showPreview;
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
// Ctrl+S or Cmd+S for manual save
if ((e.CtrlKey || e.MetaKey) && e.Key == "s")
{
await ManualSave();
}
// Ctrl+N or Cmd+N for new document
else if ((e.CtrlKey || e.MetaKey) && e.Key == "n")
{
await NewDocument();
}
}
private async Task ManualSave()
{
await SaveContent();
}
private async Task NewDocument()
{
// Ask for confirmation if there are unsaved changes
if (hasUnsavedChanges)
{
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
"You have unsaved changes. Are you sure you want to create a new document?");
if (!confirmed)
return;
}
// Simple approach: Clear current session content
markdownContent = "";
renderedHtml = "";
hasUnsavedChanges = false;
saveStatus = "New Document";
// Save empty content to current session
if (!string.IsNullOrEmpty(sessionId))
{
await SessionService.SaveSessionContentAsync(sessionId, "");
}
// Update UI
await InvokeAsync(StateHasChanged);
// Reset save status after a moment
await Task.Delay(1500);
saveStatus = "Saved";
await InvokeAsync(StateHasChanged);
}
private async Task ToggleShareDropdown()
{
if (!showShareDropdown)
{
showShareDropdown = true;
isGeneratingLinks = true;
readonlyLink = "";
editableLink = "";
await InvokeAsync(StateHasChanged);
try
{
// Generate share tokens
var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly");
var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable");
// Build URLs
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
var baseUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}";
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
editableLink = $"{baseUrl}/e/{editableToken}";
}
}
catch
{
readonlyLink = "Error generating link";
editableLink = "Error generating link";
}
finally
{
isGeneratingLinks = false;
await InvokeAsync(StateHasChanged);
}
}
else
{
CloseShareDropdown();
}
}
private void CloseShareDropdown()
{
showShareDropdown = false;
}
private async Task CopyToClipboard(string text)
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
// Could add a toast notification here in the future
}
catch
{
// Fallback for browsers that don't support clipboard API
await JSRuntime.InvokeVoidAsync("prompt", "Copy this link:", text);
}
}
public void Dispose()
{
autoSaveTimer?.Dispose();
}
// Simple splitter functionality
private ElementReference editorLayoutRef;
private double splitRatio = 0.5; // 50/50 by default
private bool isDragging = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
// Load saved split ratio from localStorage
var savedRatio = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-split-ratio");
if (!string.IsNullOrEmpty(savedRatio) && double.TryParse(savedRatio, out var ratio))
{
splitRatio = Math.Max(0.2, Math.Min(0.8, ratio));
StateHasChanged();
}
}
catch
{
// Ignore localStorage errors
}
}
}
private string GetEditorPaneStyle()
{
if (!showPreview) return "";
return $"flex: 0 0 {splitRatio * 100:F1}%;";
}
private string GetPreviewPaneStyle()
{
if (!showPreview) return "";
return $"flex: 0 0 {(1 - splitRatio) * 100:F1}%;";
}
private async Task OnSplitterMouseDown(MouseEventArgs e)
{
isDragging = true;
await JSRuntime.InvokeVoidAsync("startSplitterDrag", DotNetObjectReference.Create(this));
}
[JSInvokable]
public async Task UpdateSplitRatio(double clientX, double containerWidth)
{
if (!isDragging || containerWidth <= 0) return;
var newRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth)); // Clamp between 20% and 80%
// Update immediately for smooth dragging
splitRatio = newRatio;
// Use StateHasChanged for immediate visual feedback
StateHasChanged();
}
[JSInvokable]
public async Task StopDragging()
{
if (!isDragging) return;
isDragging = false;
// Save split ratio to localStorage when dragging stops
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2"));
}
catch
{
// Ignore localStorage errors
}
// Single StateHasChanged at the end
StateHasChanged();
}
}