Add MarkdownTablePasteService and integrate with NoteEditDialog and PageNoteDialog components

This commit introduces the MarkdownTablePasteService to facilitate markdown table pasting functionality. The service is registered in Program.cs and injected into NoteEditDialog and PageNoteDialog components. Additionally, OnAfterRenderAsync lifecycle methods are implemented in both dialog components to initialize the service after the editor is rendered, enhancing the user experience for note editing. This change supports improved markdown handling within the application.
This commit is contained in:
2026-01-17 11:33:57 -05:00
parent b4c11cd0a6
commit 84b31800ad
6 changed files with 313 additions and 1 deletions
+1
View File
@@ -25,6 +25,7 @@
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script>
</body> </body>
</html> </html>
@@ -5,6 +5,7 @@
@inject INotesService NotesService @inject INotesService NotesService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject MarkdownTablePasteService MarkdownTablePasteService
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)"> <MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
<DialogContent> <DialogContent>
@@ -42,6 +43,7 @@
public bool IsEdit { get; set; } public bool IsEdit { get; set; }
private Note _note = null!; private Note _note = null!;
private bool _pasteMarkdownInitialized = false;
private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false; private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false;
@@ -55,6 +57,17 @@
}; };
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_pasteMarkdownInitialized)
{
// Initialize paste-markdown after EasyMDE has rendered
await Task.Delay(150); // Wait for EasyMDE to initialize
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
private async Task Save() private async Task Save()
{ {
if (string.IsNullOrWhiteSpace(_note.Title)) if (string.IsNullOrWhiteSpace(_note.Title))
@@ -1,6 +1,7 @@
@namespace WebApp.Components.Shared.Components @namespace WebApp.Components.Shared.Components
@inject INotesService NotesService @inject INotesService NotesService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject MarkdownTablePasteService MarkdownTablePasteService
@implements IAsyncDisposable @implements IAsyncDisposable
<MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)"> <MudDialog Class="@MarkdownHelper.GetNoteColorClass(_note.Id)">
@@ -77,6 +78,7 @@
private bool _isEditMode = false; private bool _isEditMode = false;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false; private bool _isDisposed = false;
private bool _pasteMarkdownInitialized = false;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -94,6 +96,20 @@
await LoadPageNote(); await LoadPageNote();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _isEditMode && !_pasteMarkdownInitialized)
{
// Initialize paste-markdown after first render if already in edit mode
await Task.Delay(150); // Wait for EasyMDE to initialize
if (!_isDisposed)
{
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
}
private async Task LoadPageNote() private async Task LoadPageNote()
{ {
if (_isDisposed) return; if (_isDisposed) return;
@@ -201,11 +217,22 @@
} }
} }
private void EnterEditMode() private async Task EnterEditMode()
{ {
if (_isDisposed) return; if (_isDisposed) return;
_isEditMode = true; _isEditMode = true;
StateHasChanged(); StateHasChanged();
// Initialize paste-markdown after editor is rendered
if (!_pasteMarkdownInitialized)
{
await Task.Delay(150); // Wait for EasyMDE to initialize
if (!_isDisposed)
{
await MarkdownTablePasteService.InitializeAsync();
_pasteMarkdownInitialized = true;
}
}
} }
private void Cancel() private void Cancel()
+1
View File
@@ -194,6 +194,7 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
builder.Services.AddScoped<WebApp.Services.FormValidationService>(); builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>(); builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>(); builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
// State container for maintaining state per user connection (Blazor Server) // State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>(); builder.Services.AddScoped<StateContainer>();
@@ -0,0 +1,41 @@
using Microsoft.JSInterop;
namespace WebApp.Services;
/// <summary>
/// Service for initializing paste-markdown functionality in MarkdownEditor components.
/// Enables automatic conversion of pasted spreadsheet tables (Google Sheets, Excel) to Markdown tables.
/// </summary>
public class MarkdownTablePasteService
{
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<MarkdownTablePasteService> _logger;
public MarkdownTablePasteService(IJSRuntime jsRuntime, ILogger<MarkdownTablePasteService> logger)
{
_jsRuntime = jsRuntime;
_logger = logger;
}
/// <summary>
/// Initializes paste-markdown for a MarkdownEditor instance.
/// Should be called after the editor has been rendered and EasyMDE has initialized.
/// </summary>
/// <param name="editorId">Optional ID of the editor wrapper element. If null, will attempt to find the most recent editor.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync(string? editorId = null)
{
try
{
await _jsRuntime.InvokeVoidAsync("markdownTablePaste.initialize", editorId);
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to initialize paste-markdown for editor {EditorId}", editorId ?? "unknown");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown");
}
}
}
+229
View File
@@ -0,0 +1,229 @@
// Integration for converting pasted spreadsheet tables to Markdown tables in EasyMDE/CodeMirror
// Handles both HTML tables (from Google Sheets) and tab-separated values
window.markdownTablePaste = {
/**
* Initializes paste handler for a MarkdownEditor instance.
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
* @param {string} editorId - The ID of the editor wrapper element, or null to find by class
*/
initialize: function(editorId) {
let attempts = 0;
const maxAttempts = 5;
const tryInitialize = function() {
attempts++;
let textarea = null;
let codeMirror = null;
// Try to find the textarea element
if (editorId) {
const editorElement = document.getElementById(editorId);
if (editorElement) {
textarea = editorElement.querySelector('textarea');
}
} else {
// Find the most recently created EasyMDE textarea (last one in DOM)
const containers = document.querySelectorAll('.EasyMDEContainer');
if (containers.length > 0) {
const lastContainer = containers[containers.length - 1];
textarea = lastContainer.querySelector('textarea');
}
// Fallback: try to find any textarea near an editor-toolbar
if (!textarea) {
const toolbars = document.querySelectorAll('.editor-toolbar');
if (toolbars.length > 0) {
const lastToolbar = toolbars[toolbars.length - 1];
const nextSibling = lastToolbar.nextElementSibling;
if (nextSibling && nextSibling.tagName === 'TEXTAREA') {
textarea = nextSibling;
}
}
}
// Another fallback: find any textarea that's a child of a container with editor classes
if (!textarea) {
const allTextareas = document.querySelectorAll('textarea');
for (let ta of allTextareas) {
const parent = ta.parentElement;
if (parent && (
parent.classList.contains('EasyMDEContainer') ||
parent.classList.contains('editor') ||
parent.querySelector('.editor-toolbar')
)) {
textarea = ta;
break;
}
}
}
}
// If we found a textarea, try to get the CodeMirror instance from EasyMDE
if (textarea) {
if (window.EasyMDE) {
const easyMDEInstances = window.EasyMDE.instances || [];
for (let i = 0; i < easyMDEInstances.length; i++) {
const instance = easyMDEInstances[i];
if (instance && instance.codemirror) {
const cmTextarea = instance.codemirror.getTextArea();
if (cmTextarea === textarea) {
codeMirror = instance.codemirror;
break;
}
}
}
// Alternative: try to get CodeMirror from the textarea's parent
if (!codeMirror && textarea.parentElement) {
const parent = textarea.parentElement;
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
codeMirror = parent._easyMDEInstance.codemirror;
}
}
}
const target = codeMirror || textarea;
if (target) {
// Add paste handler
if (codeMirror) {
codeMirror.on('paste', function(cm, event) {
handleManualPaste(event, cm);
});
} else if (textarea) {
textarea.addEventListener('paste', function(event) {
handleManualPaste(event, textarea);
}, true); // Use capture phase to intercept early
}
return; // Success - we're done
}
}
// If we didn't find the textarea, retry
if (attempts < maxAttempts) {
setTimeout(tryInitialize, attempts * 100);
}
};
// Start with initial delay to allow EasyMDE to initialize
setTimeout(tryInitialize, 100);
}
};
/**
* Handler for converting pasted spreadsheet tables to Markdown tables.
* Handles both HTML tables (from Google Sheets) and tab-separated values.
*/
function handleManualPaste(event, target) {
const clipboardData = event.clipboardData || window.clipboardData;
if (!clipboardData) return;
const html = clipboardData.getData('text/html');
const text = clipboardData.getData('text/plain');
let markdownTable = null;
// Check if HTML contains a table
if (html && html.includes('<table') && html.includes('</table>')) {
markdownTable = convertHtmlTableToMarkdown(html);
}
// Check if it's tab-separated text (spreadsheet cells)
else if (text && text.includes('\t') && text.includes('\n')) {
const rows = text.trim().split(/\r?\n/).filter(row => row.trim().length > 0);
if (rows.length < 1) return;
const cells = rows.map(row => row.split('\t').map(cell => cell.trim()));
const maxCols = Math.max(...cells.map(row => row.length));
// Pad rows to have same number of columns
const paddedCells = cells.map(row => {
while (row.length < maxCols) row.push('');
return row;
});
// Escape pipe characters in cells
const escapeCell = (cell) => cell.replace(/\|/g, '\\|');
// Build Markdown table
const header = paddedCells[0];
const body = paddedCells.slice(1);
const headerRow = '| ' + header.map(escapeCell).join(' | ') + ' |';
const separatorRow = '| ' + header.map(() => '---').join(' | ') + ' |';
const bodyRows = body.map(row => '| ' + row.map(escapeCell).join(' | ') + ' |');
markdownTable = [headerRow, separatorRow, ...bodyRows].join('\n') + '\n';
}
// If we have a table to insert, do it
if (markdownTable) {
event.preventDefault();
event.stopPropagation();
// Insert into editor
if (target.getDoc && target.getDoc().replaceRange) {
// CodeMirror
const doc = target.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(markdownTable, cursor);
} else if (target.setRangeText) {
// Textarea with setRangeText
const start = target.selectionStart;
const end = target.selectionEnd;
target.setRangeText(markdownTable, start, end, 'end');
target.dispatchEvent(new Event('input', { bubbles: true }));
} else {
// Fallback: insert at cursor
const start = target.selectionStart || 0;
const end = target.selectionEnd || 0;
const before = target.value.substring(0, start);
const after = target.value.substring(end);
target.value = before + markdownTable + after;
target.selectionStart = target.selectionEnd = before.length + markdownTable.length;
target.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
/**
* Converts an HTML table to Markdown table format
*/
function convertHtmlTableToMarkdown(html) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const table = tempDiv.querySelector('table');
if (!table) return null;
const rows = table.querySelectorAll('tr');
if (rows.length === 0) return null;
const escapeCell = (cell) => {
const text = cell.textContent || cell.innerText || '';
return text.trim().replace(/\|/g, '\\|').replace(/\n/g, ' ');
};
const markdownRows = [];
// Process header row (first row)
const headerRow = rows[0];
const headerCells = headerRow.querySelectorAll('th, td');
const headerText = Array.from(headerCells).map(escapeCell);
markdownRows.push('| ' + headerText.join(' | ') + ' |');
// Add separator
markdownRows.push('| ' + headerText.map(() => '---').join(' | ') + ' |');
// Process data rows
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
const cells = row.querySelectorAll('td, th');
const cellText = Array.from(cells).map(escapeCell);
markdownRows.push('| ' + cellText.join(' | ') + ' |');
}
return markdownRows.join('\n') + '\n';
}