From b74ba4ba544f362576c11b07361ae900aaf919b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 15 Jun 2026 19:06:31 +0200 Subject: [PATCH] fix(creator): resolve editor duplication and theme synchronization issues --- .../Components/MarkdownEditor.razor | 31 ++- .../Layout/MainHubLayout.razor | 3 +- .../Layout/MainHubLayout.razor.css | 2 + .../Pages/CreatorEdit.razor | 203 ++++++++++++++---- .../Pages/CreatorEdit.razor.css | 136 +++++++----- .../wwwroot/js/milkdownWrapper.js | 83 ++++++- 6 files changed, 361 insertions(+), 97 deletions(-) diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor index e84c991..4c9fe7e 100644 --- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -42,6 +42,7 @@ private readonly CancellationTokenSource _cts = new(); private IJSObjectReference? _module; private DotNetObjectReference? _dotNetHelper; + private string? _lastInitializedEditorId; private enum SaveStatus { @@ -136,9 +137,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender || _reinitializeEditor) + var shouldInit = (firstRender || _reinitializeEditor) && (EditorId != _lastInitializedEditorId); + if (shouldInit) { _reinitializeEditor = false; + _lastInitializedEditorId = EditorId; // Set immediately before any async yield to prevent concurrent triggers if (firstRender) { @@ -153,7 +156,7 @@ { _module = await JS.InvokeAsync( "import", - "./_content/NexusReader.UI.Shared/js/milkdownWrapper.js" + $"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}" ); } await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown); @@ -178,7 +181,7 @@ { _module = await JS.InvokeAsync( "import", - "./_content/NexusReader.UI.Shared/js/milkdownWrapper.js" + $"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js?v={Guid.NewGuid():N}" ); } @@ -507,11 +510,31 @@ } } + try + { + // Always try to destroy via global window registration first to handle null _module + await JS.InvokeVoidAsync("milkdownWrapper.destroyEditor", EditorId); + } + catch + { + // Fallback to module if global is not set + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("destroyEditor", EditorId); + } + catch + { + // Fail silently + } + } + } + try { if (_module is not null) { - await _module.InvokeVoidAsync("destroyEditor", EditorId); await _module.DisposeAsync(); } } diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index a892ff7..15736fc 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -183,10 +183,11 @@ InvokeAsync(StateHasChanged); } - protected override void OnAfterRender(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { + await ThemeService.InitializeAsync(); _isFullyLoaded = true; StateHasChanged(); } diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css index 0e0c4a7..d0c1519 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor.css @@ -354,6 +354,8 @@ /* --- Desktop Sidebar: warm paper shadow --- */ .theme-light ::deep .hub-sidebar { box-shadow: 4px 0 20px rgba(139, 130, 115, 0.08); + background: var(--bg-surface) !important; + border-right: 1px solid var(--border) !important; } /* --- Logo icon: remove neon glow --- */ diff --git a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor index 39e2c83..1ecb0b3 100644 --- a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor +++ b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor @@ -2,66 +2,185 @@ @page "/creator/edit/{BookId}/{ChapterId}" @layout MainHubLayout @attribute [Authorize] +@using NexusReader.UI.Shared.Components -
- -
- -
-
-
- - 1. Rozdział 1: Wprowadzenie do Zen Mode -
-
- - 2. Rozdział 2: Zabezpieczenia i Architektura -
-
+@if (_loadingChapters) +{ +
+
+

Ładowanie struktury książki...

- -
+} +else +{ +
-
-
-

Rozdział 1: Wprowadzenie do Zen Mode

+
+ -
- ID: @ChapterId +
+ @foreach (var ch in _chapters) + { + var isActive = ch.Id == _activeChapterId; + + @if (isActive) + { +
+ + } + else + { + + } + @ch.Title +
+ }
-
+
-
-
-
- - -
+} @code { + [Inject] private HttpClient Http { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + [Parameter] public string BookId { get; set; } = string.Empty; [Parameter] public string ChapterId { get; set; } = string.Empty; - private string _retrievedMarkdown = string.Empty; - private async Task FetchContent() + private List _chapters = new(); + private Guid _parsedBookId = Guid.Empty; + private Guid _activeChapterId = Guid.Empty; + private string _activeChapterTitle = string.Empty; + private string _initialMarkdown = string.Empty; + private bool _loadingChapters = true; + private bool _loadingChapter = false; + private bool _isChapterLoaded = false; + + private class ChapterListItem { - await Task.CompletedTask; + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public int SortOrder { get; set; } + } + + private class ChapterDetail + { + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string MarkdownContent { get; set; } = string.Empty; + } + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (!Guid.TryParse(BookId, out var parsedBookId)) + { + NavigationManager.NavigateTo("/creator"); + return; + } + + _parsedBookId = parsedBookId; + + // Fetch chapters list if empty or if book ID has changed + if (_chapters.Count == 0) + { + _loadingChapters = true; + try + { + var chapters = await Http.GetFromJsonAsync>($"/api/creator/books/{_parsedBookId}/chapters"); + _chapters = chapters ?? new(); + } + catch (Exception ex) + { + Console.WriteLine($"[CreatorEdit] Error fetching chapters list: {ex.Message}"); + } + finally + { + _loadingChapters = false; + } + } + + // If ChapterId is empty/null, select the first chapter from list and navigate + if (string.IsNullOrEmpty(ChapterId)) + { + if (_chapters.Any()) + { + NavigationManager.NavigateTo($"/creator/edit/{BookId}/{_chapters.First().Id}"); + } + return; + } + + if (Guid.TryParse(ChapterId, out var parsedChapterId)) + { + // If active chapter changed, fetch its details + if (parsedChapterId != _activeChapterId) + { + _activeChapterId = parsedChapterId; + var ch = _chapters.FirstOrDefault(c => c.Id == _activeChapterId); + _activeChapterTitle = ch?.Title ?? "Rozdział"; + + _loadingChapter = true; + _isChapterLoaded = false; + StateHasChanged(); + + try + { + var detail = await Http.GetFromJsonAsync($"/api/chapters/{_activeChapterId}"); + if (detail != null) + { + _initialMarkdown = detail.MarkdownContent; + _isChapterLoaded = true; + } + } + catch (Exception ex) + { + Console.WriteLine($"[CreatorEdit] Error fetching chapter content: {ex.Message}"); + } + finally + { + _loadingChapter = false; + } + } + } } } + diff --git a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css index 4b60b84..e18ccc2 100644 --- a/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css +++ b/src/NexusReader.UI.Shared/Pages/CreatorEdit.razor.css @@ -185,7 +185,7 @@ } /* DEEP MOUNTING COMPONENT INTEROP */ -::deep .milkdown { +.milkdown-premium-container ::deep .milkdown { background: transparent !important; box-shadow: none !important; border: none !important; @@ -196,7 +196,7 @@ width: 100%; } -::deep .ProseMirror { +.milkdown-premium-container ::deep .ProseMirror { color: #e4e1d9 !important; background-color: transparent !important; font-size: 1.15rem !important; @@ -209,87 +209,132 @@ width: 100%; } -.theme-light ::deep .ProseMirror { +.theme-light .milkdown-premium-container ::deep .ProseMirror { color: #2d2a26 !important; } /* Precise matching text selection token */ -::deep .ProseMirror ::selection { +.milkdown-premium-container ::deep .ProseMirror ::selection { background-color: rgba(0, 255, 153, 0.2) !important; } -.theme-light ::deep .ProseMirror ::selection { +.theme-light .milkdown-premium-container ::deep .ProseMirror ::selection { background-color: rgba(16, 185, 129, 0.18) !important; } /* Core webkit custom scrollbar mapping */ -::deep .ProseMirror::-webkit-scrollbar { +.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar { width: 6px; } -::deep .ProseMirror::-webkit-scrollbar-track { +.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-track { background: transparent; } -::deep .ProseMirror::-webkit-scrollbar-thumb { +.milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; } -.theme-light ::deep .ProseMirror::-webkit-scrollbar-thumb { +.theme-light .milkdown-premium-container ::deep .ProseMirror::-webkit-scrollbar-thumb { background: #dcd7cc; } -/* 5. SEAMLESS INTEGRATED ACTIONS FOOTER BAR */ -.editor-footer-bar { +/* 5. SEAMLESS INTEGRATED ACTIONS FOOTER BAR (OVERWRITING FOR MARKDOWNEDITOR COMPONENT INTEGRATION) */ +.milkdown-premium-container ::deep .markdown-editor-container { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; + height: 100%; +} + +.milkdown-premium-container ::deep .milkdown-editor-wrapper { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + flex-grow: 1; + overflow: hidden !important; + display: flex; + flex-direction: column; +} + +.milkdown-premium-container ::deep .milkdown { + flex-grow: 1; + overflow: hidden !important; +} + +.milkdown-premium-container ::deep .editor-footer { display: flex; justify-content: space-between; align-items: center; - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid rgba(255, 255, 255, 0.04); + margin-top: 2rem !important; + padding: 1.5rem 0 0 0 !important; + border: none !important; + border-top: 1px solid rgba(255, 255, 255, 0.04) !important; + background: transparent !important; + border-radius: 0 !important; flex-shrink: 0; width: 100%; } -.theme-light .editor-footer-bar { - border-top: 1px solid #dcd7cc; +.theme-light .milkdown-premium-container ::deep .editor-footer { + border-top: 1px solid #dcd7cc !important; } -/* Telemetry cloud synchronization line */ -.cloud-status-container { +/* Telemetry cloud synchronization line mapping */ +.milkdown-premium-container ::deep .status-indicator { display: flex; align-items: center; gap: 12px; -} - -.cloud-status-pulse { - width: 7px; - height: 7px; - background-color: #00ff99; - border-radius: 50%; - display: inline-block; - box-shadow: 0 0 10px rgba(0, 255, 153, 0.8); -} - -.theme-light .cloud-status-pulse { - background-color: #10b981; - box-shadow: 0 0 10px rgba(16, 185, 129, 0.6); -} - -.cloud-status-text { font-family: 'Azeret Mono', monospace; font-size: 0.82rem; color: #71717a; letter-spacing: 0.1px; } +.theme-light .milkdown-premium-container ::deep .status-indicator { + color: #78716c; +} + +.milkdown-premium-container ::deep .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} + +.milkdown-premium-container ::deep .status-dot.saved { + background-color: #00ff99 !important; + box-shadow: 0 0 10px rgba(0, 255, 153, 0.8) !important; + color: #00ff99 !important; +} + +.theme-light .milkdown-premium-container ::deep .status-dot.saved { + background-color: #10b981 !important; + box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important; + color: #10b981 !important; +} + +.milkdown-premium-container ::deep .status-dot.saving { + background-color: #F59E0B !important; + box-shadow: 0 0 10px rgba(245, 158, 11, 0.8) !important; + color: #F59E0B !important; +} + +.milkdown-premium-container ::deep .status-dot.offline { + background-color: #EF4444 !important; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.8) !important; + color: #EF4444 !important; +} + /* Premium Tactile Operational Button Trigger */ -.btn-nexus-premium { +.milkdown-premium-container ::deep .nexus-btn { background-color: #00ff99 !important; color: #121214 !important; font-weight: 700; font-size: 0.9rem; letter-spacing: -0.1px; - padding: 11px 24px; + padding: 11px 24px !important; border: none !important; border-radius: 10px; cursor: pointer; @@ -298,28 +343,23 @@ gap: 10px; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 4px 20px rgba(0, 255, 153, 0.15); + height: auto !important; + min-height: unset !important; } -.theme-light .btn-nexus-premium { +.theme-light .milkdown-premium-container ::deep .nexus-btn { background-color: #10b981 !important; color: #ffffff !important; box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15); } -.btn-nexus-premium i { - font-size: 0.85rem; - transition: transform 0.2s ease; -} - -.btn-nexus-premium:hover { +.milkdown-premium-container ::deep .nexus-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0, 255, 153, 0.3); } -.theme-light .btn-nexus-premium:hover { +.theme-light .milkdown-premium-container ::deep .nexus-btn:hover { box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3); } -.btn-nexus-premium:hover i { - transform: translateX(3px); -} + diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js index 94226bb..c8ecf95 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js @@ -1,5 +1,11 @@ -// Map to keep track of active Crepe editor instances by elementId (container ID) -const editorCache = new Map(); +// Initialize global stores on window to share state across dynamically imported module instances (preventing cache-buster isolation) +if (typeof window !== 'undefined') { + if (!window.editorCache) window.editorCache = new Map(); + if (!window.editorStates) window.editorStates = new Map(); +} + +const editorCache = typeof window !== 'undefined' ? window.editorCache : new Map(); +const editorStates = typeof window !== 'undefined' ? window.editorStates : new Map(); /** * Asynchronously injects a stylesheet link tag into the document head @@ -23,19 +29,64 @@ async function ensureStylesheet(href) { * Initializes a Milkdown Crepe editor on the specified element. */ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { + // Check if already destroyed or initializing + if (editorStates.get(elementId) === 'destroyed') { + console.warn(`[Milkdown] initEditor called on already destroyed element: ${elementId}. Aborting.`); + return; + } + if (editorStates.get(elementId) === 'initializing' || editorStates.get(elementId) === 'ready') { + console.warn(`[Milkdown] Editor is already initializing or ready for element: ${elementId}. Ignoring.`); + return; + } + + editorStates.set(elementId, 'initializing'); + + // Guard 1: Destroy previous cached editor instance with the same ID if it exists + if (editorCache.has(elementId)) { + console.warn(`[Milkdown] Editor instance already exists in cache for: ${elementId}. Destroying first.`); + await destroyEditor(elementId); + } + const container = document.getElementById(elementId); if (!container) { console.error(`[Milkdown] Container with ID "${elementId}" not found.`); + editorStates.delete(elementId); return; } + // Guard 2: Clear container children to prevent double-initialization of crepe editor DOM + if (container.children.length > 0) { + console.warn(`[Milkdown] Container "${elementId}" is not empty. Clearing children before initialization.`); + container.innerHTML = ''; + } + + // Guard 3: Search the parent workspace card to purge any other leftover editor components + const parentCard = container.closest('.milkdown-premium-container') || container.parentElement; + if (parentCard) { + const existingEditors = parentCard.querySelectorAll('.milkdown, .crepe'); + if (existingEditors.length > 0) { + console.warn(`[Milkdown] Found ${existingEditors.length} leftover editor DOM elements in the workspace card. Purging them.`); + existingEditors.forEach(el => el.remove()); + } + } + try { // Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor await ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css'); + if (editorStates.get(elementId) === 'destroyed') { + console.warn(`[Milkdown] Element ${elementId} destroyed during stylesheet loading. Aborting.`); + return; + } + // Dynamically import the local JS bundle await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js'); + if (editorStates.get(elementId) === 'destroyed') { + console.warn(`[Milkdown] Element ${elementId} destroyed during crepe bundle loading. Aborting.`); + return; + } + // Get Crepe constructor from the global window.milkdownCrepe namespace const Crepe = window.milkdownCrepe?.Crepe; if (!Crepe) { @@ -100,6 +151,7 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { clearTimeout(debounceTimeout); } debounceTimeout = setTimeout(() => { + if (editorStates.get(elementId) === 'destroyed') return; dotNetHelper.invokeMethodAsync('OnEditorContentChanged', markdown) .catch(err => console.error("[Milkdown] Failed to notify editor content changed:", err)); }, 300); @@ -112,8 +164,17 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { // Create the editor view asynchronously await crepe.create(); + if (editorStates.get(elementId) === 'destroyed') { + console.warn(`[Milkdown] Element ${elementId} destroyed during crepe.create(). Cleaning up.`); + await crepe.destroy(); + editorCache.delete(elementId); + return; + } + + editorStates.set(elementId, 'ready'); console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`); } catch (error) { + editorStates.delete(elementId); console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error); } } @@ -134,6 +195,8 @@ export function getMarkdownContent(elementId) { * Safely disposes of the editor instance to prevent memory leaks in WASM. */ export async function destroyEditor(elementId) { + editorStates.set(elementId, 'destroyed'); + const crepe = editorCache.get(elementId); if (crepe) { try { @@ -144,6 +207,12 @@ export async function destroyEditor(elementId) { } editorCache.delete(elementId); } + + // Explicitly clean up container DOM children + const container = document.getElementById(elementId); + if (container) { + container.innerHTML = ''; + } } /** @@ -163,3 +232,13 @@ export function getBackupKeys() { } return keys; } + +// Attach to window for global access (especially from DisposeAsync when module reference is null) +if (typeof window !== 'undefined') { + window.milkdownWrapper = { + initEditor, + getMarkdownContent, + destroyEditor, + getBackupKeys + }; +}