870 lines
32 KiB
Plaintext
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 • @charCount chars • @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></></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">
|
|
• 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();
|
|
}
|
|
} |