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.
434 lines
14 KiB
Plaintext
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();
|
|
}
|
|
} |