diff --git a/WebApp/Components/App.razor b/WebApp/Components/App.razor index 2445bcf..32271cd 100644 --- a/WebApp/Components/App.razor +++ b/WebApp/Components/App.razor @@ -25,6 +25,7 @@ + diff --git a/WebApp/Components/Pages/NoteEditDialog.razor b/WebApp/Components/Pages/NoteEditDialog.razor index 83e2294..7b1ea86 100644 --- a/WebApp/Components/Pages/NoteEditDialog.razor +++ b/WebApp/Components/Pages/NoteEditDialog.razor @@ -5,6 +5,7 @@ @inject INotesService NotesService @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject MarkdownTablePasteService MarkdownTablePasteService @@ -42,6 +43,7 @@ public bool IsEdit { get; set; } private Note _note = null!; + private bool _pasteMarkdownInitialized = 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() { if (string.IsNullOrWhiteSpace(_note.Title)) diff --git a/WebApp/Components/Shared/Components/PageNoteDialog.razor b/WebApp/Components/Shared/Components/PageNoteDialog.razor index 81c7916..4965257 100644 --- a/WebApp/Components/Shared/Components/PageNoteDialog.razor +++ b/WebApp/Components/Shared/Components/PageNoteDialog.razor @@ -1,6 +1,7 @@ @namespace WebApp.Components.Shared.Components @inject INotesService NotesService @inject ISnackbar Snackbar +@inject MarkdownTablePasteService MarkdownTablePasteService @implements IAsyncDisposable @@ -77,6 +78,7 @@ private bool _isEditMode = false; private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed = false; + private bool _pasteMarkdownInitialized = false; protected override void OnInitialized() { @@ -94,6 +96,20 @@ 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() { if (_isDisposed) return; @@ -201,11 +217,22 @@ } } - private void EnterEditMode() + private async Task EnterEditMode() { if (_isDisposed) return; _isEditMode = true; 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() diff --git a/WebApp/Program.cs b/WebApp/Program.cs index a30f86b..5276730 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -194,6 +194,7 @@ builder.Services.AddScoped(sp => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); diff --git a/WebApp/Services/MarkdownTablePasteService.cs b/WebApp/Services/MarkdownTablePasteService.cs new file mode 100644 index 0000000..c7c918d --- /dev/null +++ b/WebApp/Services/MarkdownTablePasteService.cs @@ -0,0 +1,41 @@ +using Microsoft.JSInterop; + +namespace WebApp.Services; + +/// +/// Service for initializing paste-markdown functionality in MarkdownEditor components. +/// Enables automatic conversion of pasted spreadsheet tables (Google Sheets, Excel) to Markdown tables. +/// +public class MarkdownTablePasteService +{ + private readonly IJSRuntime _jsRuntime; + private readonly ILogger _logger; + + public MarkdownTablePasteService(IJSRuntime jsRuntime, ILogger logger) + { + _jsRuntime = jsRuntime; + _logger = logger; + } + + /// + /// Initializes paste-markdown for a MarkdownEditor instance. + /// Should be called after the editor has been rendered and EasyMDE has initialized. + /// + /// Optional ID of the editor wrapper element. If null, will attempt to find the most recent editor. + /// A task that represents the asynchronous operation. + 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"); + } + } +} diff --git a/WebApp/wwwroot/js/markdownTablePaste.js b/WebApp/wwwroot/js/markdownTablePaste.js new file mode 100644 index 0000000..5313626 --- /dev/null +++ b/WebApp/wwwroot/js/markdownTablePaste.js @@ -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('')) { + 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'; +}