Files
Notemark/Notemark/Components/Pages/Editor.razor
T
2026-09-22 12:53:08 +02:00

870 lines
32 KiB
Plaintext

@page "/editor"
@page "/editor/{DocumentId}"
@page "/doc/{DocumentId}"
@rendermode InteractiveServer
@implements IDisposable
@using Notemark.Services
@using Notemark.Models
@inject ISessionService SessionService
@inject IDocumentService DocumentService
@inject IMarkdownService MarkdownService
@inject IShareService ShareService
@inject IJSRuntime JSRuntime
@inject IHttpContextAccessor HttpContextAccessor
@inject NavigationManager Navigation
<PageTitle>@(string.IsNullOrWhiteSpace(documentTitle) ? "Notemark" : $"{documentTitle} - Notemark")</PageTitle>
<div class="editor-container">
<!-- Top Bar -->
<div class="top-bar">
<div class="top-bar-left">
<button class="btn-topbar btn-home-nav" @onclick="GoToHome" title="Back to Home Dashboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span class="btn-text">Home</span>
</button>
<button class="btn-topbar btn-notes" @onclick="ToggleNotesDrawer" title="View all & flagged notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<span class="btn-text">Notes</span>
@if (flaggedCount > 0)
{
<span class="notes-badge">@flaggedCount</span>
}
</button>
<button class="btn-topbar btn-new" @onclick="NewDocument" title="Create new document (Ctrl+N)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="btn-text">New</span>
</button>
<button class="btn-topbar btn-flag @(isFlagged ? "flagged" : "")" @onclick="ToggleFlag" title="@(isFlagged ? "Flagged (Click to unflag)" : "Flag this note")">
<span>@(isFlagged ? "🚩" : "🏳️")</span>
</button>
<div class="doc-title-wrapper" title="Document Title">
<input class="doc-title-input"
type="text"
placeholder="Untitled Note..."
@bind="documentTitle"
@bind:event="oninput"
@onchange="OnTitleChanged" />
</div>
<!-- Tags in top bar -->
@if (currentTags.Any())
{
<div class="topbar-tags">
@foreach (var tag in currentTags.Take(3))
{
<span class="topbar-tag-pill">#@tag</span>
}
</div>
}
</div>
<div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()"
title="@(saveErrorMessage != null ? $"Save Error: {saveErrorMessage} (Click to retry)" : (saveStatus == "Saved" ? "All changes saved" : saveStatus))"
@onclick="ManualSave"
style="@(saveStatus == "Error" ? "cursor: pointer;" : "")">
@saveStatus @(saveStatus == "Error" ? "⚠️" : "")
</span>
<span class="doc-stats" title="Words • Characters • Reading Time">
@wordCount words &bull; @charCount chars &bull; @readingTime min read
</span>
</div>
<div class="top-bar-right">
<button class="btn-topbar btn-toggle @(showPreview ? "active" : "")" @onclick="TogglePreview" title="Toggle Live Preview">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="btn-text">@(showPreview ? "Hide Preview" : "Preview")</span>
</button>
<button class="btn-topbar btn-save" @onclick="ManualSave" title="Save document (Ctrl+S)" disabled="@(!hasUnsavedChanges)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<!-- Export / Actions Dropdown -->
<div class="export-container">
<button class="btn-topbar btn-export" @onclick="ToggleExportDropdown" title="Export & Print Options">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span class="btn-text">Export</span>
</button>
@if (showExportDropdown)
{
<div class="dropdown-backdrop" @onclick="() => showExportDropdown = false"></div>
<div class="export-dropdown">
<button class="export-menu-item" @onclick="ExportMarkdown">
<span>📄 Download Markdown (.md)</span>
</button>
<button class="export-menu-item" @onclick="ExportHtml">
<span>🌐 Download Styled HTML</span>
</button>
<button class="export-menu-item" @onclick="PrintDocument">
<span>🖨️ Print / Save as PDF</span>
</button>
</div>
}
</div>
<!-- Share Dropdown -->
<div class="share-container">
<button class="btn-topbar btn-share" @onclick="ToggleShareDropdown">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
<span class="btn-text">Share</span>
</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" title="Copy view link">
@(copiedLink == readonlyLink ? "✓" : "📋")
</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" title="Copy edit link">
@(copiedLink == editableLink ? "✓" : "📋")
</button>
</div>
</div>
}
</div>
}
</div>
<ThemeToggle />
</div>
</div>
<!-- Format Toolbar with Undo / Redo -->
<div class="format-toolbar">
<button type="button" class="toolbar-btn" @onclick="Undo" disabled="@(!canUndo)" title="Undo (Ctrl+Z)">
↩️
</button>
<button type="button" class="toolbar-btn" @onclick="Redo" disabled="@(!canRedo)" title="Redo (Ctrl+Y)">
↪️
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("**", "**", "bold text")' title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("*", "*", "italic text")' title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("### ", "", "Heading")' title="Heading 3">
H3
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("`", "`", "code")' title="Inline Code">
<code>&lt;/&gt;</code>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("```csharp\n", "\n```", "// Write code here")' title="Code Block">
<code>{ }</code>
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("```mermaid\ngraph TD;\n A[Start] --> B(Process);\n B --> C{Done?};\n C -->|Yes| D[End];\n", "\n```", "")' title="Mermaid Diagram">
📊 Diagram
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("[", "](https://example.com)", "link title")' title="Insert Link (Ctrl+K)">
🔗 Link
</button>
<span class="toolbar-divider"></span>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("- [ ] ", "", "Task item")' title="Task checkbox">
☑️ Task
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("- ", "", "List item")' title="Bullet List">
&bull; List
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("> ", "", "Quote text")' title="Blockquote">
❝ Quote
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("> [!NOTE]\n> ", "", "Helpful information here")' title="Note Callout">
💡 Note
</button>
<button type="button" class="toolbar-btn" @onclick='() => InsertFormatting("| Column 1 | Column 2 |\n|---|---|\n| Cell 1 | Cell 2 |", "", "")' title="Table">
📋 Table
</button>
</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"
id="notemark-editor-textarea"
@ref="editorTextareaRef"
@bind="markdownContent"
@oninput="OnContentChanged"
@onkeydown="OnKeyDown"
placeholder="Start typing your markdown... Use # for headings, **bold**, *italic*, `code`, tables, task lists, and paste images (Ctrl+V)..."
spellcheck="true">
</textarea>
</div>
<!-- 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-header-bar">
<span class="preview-label">Live Preview</span>
<div class="preview-zoom-controls" title="Zoom: Scroll wheel over controls, or Ctrl + Scroll over preview">
<button type="button" class="btn-zoom" @onclick="ZoomOut" title="Zoom out (min 70%)" disabled="@(previewZoom <= 70)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button type="button" class="btn-zoom-reset" @onclick="ResetZoom" title="Reset zoom to 100% (or Ctrl+Scroll to zoom)">
@(previewZoom)%
</button>
<button type="button" class="btn-zoom" @onclick="ZoomIn" title="Zoom in (max 200%)" disabled="@(previewZoom >= 200)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
</div>
<div class="preview-content" style="@GetPreviewContentStyle()">
@((MarkupString)renderedHtml)
</div>
</div>
}
</div>
<!-- Notes Drawer Modal -->
<NotesDrawer IsOpen="@showNotesDrawer"
IsOpenChanged="@((val) => showNotesDrawer = val)"
CurrentDocumentId="@sessionId"
OnSelectDocument="@OnDocumentSelected" />
<!-- Secret Master Vault Modal -->
<VaultModal IsOpen="@showVaultModal"
IsOpenChanged="@((val) => showVaultModal = val)"
OnSelectDocument="@OnDocumentSelected" />
</div>
@code {
[Parameter] public string? DocumentId { get; set; }
private bool showVaultModal = false;
private string sessionId = "";
private string documentTitle = "Untitled Note";
private bool isFlagged = false;
private int flaggedCount = 0;
private List<string> currentTags = new();
private string markdownContent = "";
private string renderedHtml = "";
private bool showPreview = true;
private string saveStatus = "Saved";
private string? saveErrorMessage = null;
private bool showNotesDrawer = false;
private bool showExportDropdown = false;
private ElementReference editorTextareaRef;
private ElementReference editorLayoutRef;
private DotNetObjectReference<Editor>? dotNetRef;
private double splitRatio = 0.5;
private bool isDragging = false;
private int previewZoom = 100;
// Undo / Redo History Stack
private readonly List<string> undoStack = new();
private readonly List<string> redoStack = new();
private bool canUndo => undoStack.Count > 0;
private bool canRedo => redoStack.Count > 0;
// Word stats
private int wordCount = 0;
private int charCount = 0;
private int readingTime = 1;
// Share dropdown state
private bool showShareDropdown = false;
private bool isGeneratingLinks = false;
private string readonlyLink = "";
private string editableLink = "";
private string copiedLink = "";
// Auto-save timer
private Timer? autoSaveTimer;
private bool hasUnsavedChanges = false;
protected override async Task OnInitializedAsync()
{
var httpContext = HttpContextAccessor.HttpContext;
if (!string.IsNullOrEmpty(DocumentId))
{
sessionId = DocumentId;
}
else if (httpContext != null)
{
sessionId = SessionService.GetOrCreateSessionId(httpContext);
}
else
{
sessionId = Guid.NewGuid().ToString();
}
await LoadDocumentData();
}
private async Task LoadDocumentData()
{
markdownContent = await SessionService.LoadSessionContentAsync(sessionId);
var meta = await DocumentService.GetDocumentAsync(sessionId);
if (meta != null)
{
documentTitle = meta.Title;
isFlagged = meta.IsFlagged;
currentTags = meta.Tags;
}
else
{
documentTitle = DocumentService.ExtractTitle(markdownContent);
isFlagged = false;
currentTags = DocumentService.ExtractTags(markdownContent);
}
undoStack.Clear();
redoStack.Clear();
UpdateStatistics();
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
await UpdateFlaggedCount();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
// Save current document ID to localStorage as the last worked on note on this device
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-last-doc-id", sessionId);
// Initialize Tab handling, image paste/drop, and scroll sync
await JSRuntime.InvokeVoidAsync("notemarkEditor.initTextarea", editorTextareaRef);
// Load saved split ratio
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();
}
// Load saved preview zoom
var savedZoom = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", "notemark-preview-zoom");
if (!string.IsNullOrEmpty(savedZoom) && int.TryParse(savedZoom, out var z))
{
previewZoom = Math.Clamp(z, 70, 200);
StateHasChanged();
}
// Attach mouse wheel zoom listener (Ctrl + Wheel over preview or direct scroll over zoom controls)
dotNetRef ??= DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("notemarkEditor.attachPreviewZoomWheel", ".preview-pane", dotNetRef);
}
catch { }
}
// Highlight code and render Mermaid in preview
if (showPreview && !string.IsNullOrEmpty(renderedHtml))
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.highlightPreview", ".preview-content");
}
catch { }
}
}
private void UpdateStatistics()
{
charCount = markdownContent.Length;
var words = markdownContent.Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries);
wordCount = words.Length;
readingTime = Math.Max(1, (int)Math.Ceiling(wordCount / 200.0));
currentTags = DocumentService.ExtractTags(markdownContent);
}
private async Task OnContentChanged(ChangeEventArgs e)
{
var newContent = e.Value?.ToString() ?? "";
if (newContent != markdownContent)
{
// Push to undo stack (max 50 states)
if (undoStack.Count > 50) undoStack.RemoveAt(0);
undoStack.Add(markdownContent);
redoStack.Clear();
markdownContent = newContent;
hasUnsavedChanges = true;
UpdateStatistics();
if (documentTitle == "Untitled Note" || string.IsNullOrWhiteSpace(documentTitle))
{
documentTitle = DocumentService.ExtractTitle(markdownContent);
}
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
// Debounced autosave
autoSaveTimer?.Dispose();
autoSaveTimer = new Timer(async _ =>
{
await InvokeAsync(async () => await AutoSave());
}, null, 1500, Timeout.Infinite);
if (saveStatus == "Saved")
{
saveStatus = "Modified";
StateHasChanged();
}
}
}
private void Undo()
{
if (canUndo)
{
var prev = undoStack[^1];
undoStack.RemoveAt(undoStack.Count - 1);
redoStack.Add(markdownContent);
markdownContent = prev;
hasUnsavedChanges = true;
UpdateStatistics();
if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent);
StateHasChanged();
}
}
private void Redo()
{
if (canRedo)
{
var next = redoStack[^1];
redoStack.RemoveAt(redoStack.Count - 1);
undoStack.Add(markdownContent);
markdownContent = next;
hasUnsavedChanges = true;
UpdateStatistics();
if (showPreview) renderedHtml = MarkdownService.RenderToHtml(markdownContent);
StateHasChanged();
}
}
private async Task OnTitleChanged(ChangeEventArgs e)
{
documentTitle = e.Value?.ToString() ?? "Untitled Note";
hasUnsavedChanges = true;
await SaveContent();
}
private async Task AutoSave()
{
if (hasUnsavedChanges && !string.IsNullOrEmpty(sessionId))
{
await SaveContent();
}
}
private async Task SaveContent()
{
if (string.IsNullOrEmpty(sessionId)) return;
saveStatus = "Saving...";
saveErrorMessage = null;
StateHasChanged();
try
{
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags);
hasUnsavedChanges = false;
saveStatus = "Saved";
await UpdateFlaggedCount();
}
catch (Exception ex)
{
saveErrorMessage = ex.Message;
saveStatus = "Error";
Console.Error.WriteLine($"[Notemark Save Error] Session: {sessionId} - {ex}");
}
StateHasChanged();
}
private async Task ToggleFlag()
{
isFlagged = await DocumentService.ToggleFlagAsync(sessionId);
await UpdateFlaggedCount();
}
private async Task UpdateFlaggedCount()
{
try
{
var flagged = await DocumentService.GetFlaggedDocumentsAsync();
flaggedCount = flagged.Count;
}
catch { }
}
private void ToggleNotesDrawer()
{
showNotesDrawer = !showNotesDrawer;
}
private void ToggleExportDropdown()
{
showExportDropdown = !showExportDropdown;
}
private async Task OnDocumentSelected(string docId)
{
if (hasUnsavedChanges)
{
await SaveContent();
}
sessionId = docId;
await LoadDocumentData();
StateHasChanged();
}
private void TogglePreview()
{
showPreview = !showPreview;
if (showPreview)
{
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
}
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("s", StringComparison.OrdinalIgnoreCase))
{
await ManualSave();
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("n", StringComparison.OrdinalIgnoreCase))
{
await NewDocument();
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && !e.ShiftKey)
{
Undo();
}
else if ((e.CtrlKey || e.MetaKey) && (e.Key.Equals("y", StringComparison.OrdinalIgnoreCase) || (e.Key.Equals("z", StringComparison.OrdinalIgnoreCase) && e.ShiftKey)))
{
Redo();
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("b", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("**", "**", "bold text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("i", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("*", "*", "italic text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("k", StringComparison.OrdinalIgnoreCase))
{
await InsertFormatting("[", "](https://example.com)", "link text");
}
else if ((e.CtrlKey || e.MetaKey) && e.Key.Equals("p", StringComparison.OrdinalIgnoreCase))
{
await PrintDocument();
}
else if ((e.CtrlKey || e.MetaKey) && (e.ShiftKey || e.AltKey) && e.Key.Equals("a", StringComparison.OrdinalIgnoreCase))
{
showVaultModal = true;
StateHasChanged();
}
}
private async Task ManualSave()
{
await SaveContent();
}
private async Task NewDocument()
{
if (hasUnsavedChanges)
{
await SaveContent();
}
sessionId = Guid.NewGuid().ToString();
markdownContent = "# Untitled Note\n\nStart typing here... #ideas";
documentTitle = "Untitled Note";
isFlagged = false;
hasUnsavedChanges = false;
saveStatus = "Saved";
undoStack.Clear();
redoStack.Clear();
UpdateStatistics();
renderedHtml = MarkdownService.RenderToHtml(markdownContent);
await DocumentService.SaveDocumentAsync(sessionId, markdownContent, isFlagged, documentTitle, currentTags);
await SessionService.SaveSessionContentAsync(sessionId, markdownContent);
await UpdateFlaggedCount();
StateHasChanged();
}
private async Task InsertFormatting(string prefix, string suffix, string defaultText)
{
try
{
await JSRuntime.InvokeVoidAsync("notemarkEditor.wrapSelection", editorTextareaRef, prefix, suffix, defaultText);
}
catch { }
}
private async Task ExportMarkdown()
{
showExportDropdown = false;
var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.md";
await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadMarkdown", filename, markdownContent);
}
private async Task ExportHtml()
{
showExportDropdown = false;
var filename = $"{documentTitle.Replace(" ", "_").ToLowerInvariant()}.html";
var currentTheme = await JSRuntime.InvokeAsync<string>("notemarkTheme.getCurrentTheme");
await JSRuntime.InvokeVoidAsync("notemarkEditor.downloadHtml", filename, documentTitle, renderedHtml, currentTheme);
}
private async Task PrintDocument()
{
showExportDropdown = false;
await JSRuntime.InvokeVoidAsync("notemarkEditor.printDocument");
}
private async Task ToggleShareDropdown()
{
if (!showShareDropdown)
{
showShareDropdown = true;
isGeneratingLinks = true;
readonlyLink = "";
editableLink = "";
StateHasChanged();
try
{
var readonlyToken = await ShareService.CreateShareTokenAsync(sessionId, "readonly");
var editableToken = await ShareService.CreateShareTokenAsync(sessionId, "editable");
var httpContext = HttpContextAccessor.HttpContext;
var baseUrl = httpContext != null
? $"{httpContext.Request.Scheme}://{httpContext.Request.Host}"
: Navigation.BaseUri.TrimEnd('/');
readonlyLink = $"{baseUrl}/s/{readonlyToken}";
editableLink = $"{baseUrl}/e/{editableToken}";
}
catch
{
readonlyLink = "Error generating link";
editableLink = "Error generating link";
}
finally
{
isGeneratingLinks = false;
StateHasChanged();
}
}
else
{
CloseShareDropdown();
}
}
private void CloseShareDropdown()
{
showShareDropdown = false;
copiedLink = "";
}
private async Task CopyToClipboard(string text)
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
copiedLink = text;
StateHasChanged();
_ = Task.Delay(2000).ContinueWith(_ =>
{
copiedLink = "";
InvokeAsync(StateHasChanged);
});
}
catch
{
await JSRuntime.InvokeVoidAsync("prompt", "Copy this link:", text);
}
}
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;
dotNetRef?.Dispose();
dotNetRef = DotNetObjectReference.Create(this);
await JSRuntime.InvokeVoidAsync("startSplitterDrag", dotNetRef);
}
[JSInvokable]
public async Task UpdateSplitRatio(double clientX, double containerWidth)
{
if (!isDragging || containerWidth <= 0) return;
splitRatio = Math.Max(0.2, Math.Min(0.8, clientX / containerWidth));
StateHasChanged();
}
[JSInvokable]
public async Task StopDragging()
{
if (!isDragging) return;
isDragging = false;
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-split-ratio", splitRatio.ToString("F2"));
}
catch { }
StateHasChanged();
}
[JSInvokable]
public void AdjustZoom(int delta)
{
previewZoom = Math.Clamp(previewZoom + delta, 70, 200);
SavePreviewZoom();
StateHasChanged();
}
private void ZoomIn()
{
previewZoom = Math.Min(200, previewZoom + 10);
SavePreviewZoom();
StateHasChanged();
}
private void ZoomOut()
{
previewZoom = Math.Max(70, previewZoom - 10);
SavePreviewZoom();
StateHasChanged();
}
private void ResetZoom()
{
previewZoom = 100;
SavePreviewZoom();
StateHasChanged();
}
private async void SavePreviewZoom()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "notemark-preview-zoom", previewZoom.ToString());
}
catch { }
}
private string GetPreviewContentStyle()
{
var factor = (previewZoom / 100.0).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
return $"zoom: {factor}; font-size: calc(1rem * {factor}); --preview-zoom: {factor};";
}
private async Task GoToHome()
{
if (hasUnsavedChanges)
{
await SaveContent();
}
Navigation.NavigateTo("/");
}
public void Dispose()
{
autoSaveTimer?.Dispose();
dotNetRef?.Dispose();
}
}