< Summary

Information
Class: Chronicis.Client.Components.Shared.PrivateNotesTipTapEditor
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Shared/PrivateNotesTipTapEditor.razor
Line coverage
100%
Covered lines: 69
Uncovered lines: 0
Coverable lines: 69
Total lines: 766
Line coverage: 100%
Branch coverage
100%
Covered branches: 28
Total branches: 28
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Shared/PrivateNotesTipTapEditor.razor

#LineLine coverage
 1@using Chronicis.Client.Components.Articles
 2@using Chronicis.Client.Components.Maps
 3@using Chronicis.Client.Services
 4@using Chronicis.Shared.DTOs
 5@using Microsoft.JSInterop
 6@using ArticleWikiLinkAutocompleteItem = Chronicis.Client.Components.Articles.WikiLinkAutocompleteItem
 7@implements IAsyncDisposable
 8@inject IJSRuntime JSRuntime
 9@inject ILinkApiService LinkApiService
 10@inject IExternalLinkApiService ExternalLinkApiService
 11@inject IMapApiService MapApi
 12@inject IWikiLinkService WikiLinkService
 13@inject IArticleCacheService ArticleCache
 14@inject IAISummaryApiService SummaryApi
 15@inject IWorldApiService WorldApi
 16@inject NavigationManager Navigation
 17@inject ISnackbar Snackbar
 18@inject ILogger<PrivateNotesTipTapEditor> Logger
 19@inject IDrawerCoordinator DrawerCoordinator
 20
 3721@if (ReadOnly)
 22{
 323    @if (!string.IsNullOrWhiteSpace(Value))
 24    {
 25        <MudPaper Outlined="true" Class="pa-4">
 26            @((MarkupString)Value)
 27        </MudPaper>
 28    }
 29    else
 30    {
 31        <MudAlert Severity="Severity.Info">
 32            No private notes yet.
 33        </MudAlert>
 34    }
 35}
 36else
 37{
 38    <div id="@EditorElementId" class="chronicis-editor-container mb-2"></div>
 39
 3440    @if (_showAutocomplete)
 41    {
 42        <ArticleDetailWikiLinkAutocomplete Suggestions="@_autocompleteSuggestions"
 43                                           Loading="@_autocompleteLoading"
 44                                           SelectedIndex="@_autocompleteSelectedIndex"
 45                                           SelectedIndexChanged="OnAutocompleteIndexChanged"
 46                                           OnSelect="OnAutocompleteSelect"
 47                                           OnCreate="OnAutocompleteCreate"
 48                                           Position="@_autocompletePosition"
 49                                           Query="@_autocompleteQuery"
 50                                           IsExternalQuery="@_autocompleteIsExternalQuery" />
 51    }
 52
 53    <MudDrawer @bind-Open="_externalPreviewOpen"
 54               Anchor="Anchor.End"
 55               Variant="@DrawerVariant.Temporary"
 56               Elevation="9999"
 57               Class="external-link-preview-drawer">
 58        <MudDrawerHeader>
 59            <MudSpacer />
 60            <MudIconButton Icon="@Icons.Material.Filled.Close" OnClick="CloseExternalPreview" />
 61        </MudDrawerHeader>
 62        <MudDrawerContainer>
 63            <div class="external-link-preview-header">
 64                @if (!string.IsNullOrWhiteSpace(_externalPreviewSource))
 65                {
 66                    <span class="external-link-preview-source">@_externalPreviewSource.ToUpperInvariant()</span>
 67                }
 68                <MudText Typo="Typo.h6">@(_externalPreviewTitle ?? "External Link")</MudText>
 69            </div>
 70            @if (!string.IsNullOrWhiteSpace(_externalPreviewContent?.Kind))
 71            {
 72                <MudText Typo="Typo.caption" Style="color: var(--mud-palette-text-secondary);">
 73                    @_externalPreviewContent.Kind
 74                </MudText>
 75            }
 76            @if (_externalPreviewLoading)
 77            {
 78                <div class="d-flex justify-center align-center" style="padding: 24px;">
 79                    <MudProgressCircular Indeterminate="true" />
 80                </div>
 81            }
 82            else if (!string.IsNullOrWhiteSpace(_externalPreviewError))
 83            {
 84                <MudAlert Severity="Severity.Error">@_externalPreviewError</MudAlert>
 85            }
 86            else if (_externalPreviewContent != null)
 87            {
 88                <ExternalLinkDetailPanel Content="_externalPreviewContent" />
 89            }
 90        </MudDrawerContainer>
 91    </MudDrawer>
 92
 3493    @if (_isMapModalOpen)
 94    {
 95        <SessionMapViewerModal IsOpen="_isMapModalOpen"
 96                               WorldId="WorldId"
 97                               MapId="_selectedMapId"
 98                               MapName="@_selectedMapName"
 99                               OnClose="CloseMapModalAsync" />
 100    }
 101}
 102
 103@code {
 104    [Parameter]
 105    public Guid WorldId { get; set; }
 106
 107    [Parameter]
 108    public string? Value { get; set; }
 109
 110    [Parameter]
 111    public EventCallback<string> ValueChanged { get; set; }
 112
 113    [Parameter]
 114    public bool ReadOnly { get; set; }
 115
 116    [Parameter]
 117    public string UploadContextLabel { get; set; } = "Private Notes";
 118
 4119    private readonly string _instanceId = Guid.NewGuid().ToString("N");
 61120    private string EditorElementId => $"private-notes-editor-{_instanceId}";
 121
 122    private bool _editorInitialized;
 123    private bool _disposed;
 124    private bool _suppressEditorUpdates;
 4125    private string _editorValue = string.Empty;
 126    private DotNetObjectReference<PrivateNotesTipTapEditor>? _dotNetRef;
 127
 128    private bool _showAutocomplete;
 129    private bool _autocompleteLoading;
 4130    private List<ArticleWikiLinkAutocompleteItem> _autocompleteSuggestions = new();
 131    private int _autocompleteSelectedIndex;
 4132    private (double X, double Y) _autocompletePosition = (0, 0);
 4133    private string _autocompleteQuery = string.Empty;
 134    private bool _autocompleteIsExternalQuery;
 135    private string? _autocompleteExternalSourceKey;
 136
 137    private bool _externalPreviewOpen;
 138    private bool _externalPreviewLoading;
 139    private string? _externalPreviewError;
 140    private ExternalLinkContentDto? _externalPreviewContent;
 141    private string? _externalPreviewSource;
 142    private string? _externalPreviewTitle;
 4143    private readonly Dictionary<string, ExternalLinkContentDto> _externalLinkCache = new(StringComparer.OrdinalIgnoreCas
 144    private bool _isMapModalOpen;
 145    private Guid _selectedMapId;
 146    private string? _selectedMapName;
 147
 148    protected override void OnInitialized()
 149    {
 4150        _dotNetRef = DotNetObjectReference.Create(this);
 4151        _editorValue = Value ?? string.Empty;
 4152        DrawerCoordinator.OnChanged += HandleDrawerCoordinatorChanged;
 4153    }
 154
 155    protected override async Task OnParametersSetAsync()
 156    {
 157        var incoming = Value ?? string.Empty;
 158
 159        if (!_editorInitialized)
 160        {
 161            _editorValue = incoming;
 162            return;
 163        }
 164
 165        if (ReadOnly)
 166        {
 167            await DestroyEditorAsync();
 168            _editorValue = incoming;
 169            return;
 170        }
 171
 172        if (!string.Equals(incoming, _editorValue, StringComparison.Ordinal))
 173        {
 174            _editorValue = incoming;
 175            _suppressEditorUpdates = true;
 176            try
 177            {
 178                await JSRuntime.InvokeVoidAsync("setTipTapContent", EditorElementId, _editorValue);
 179                await JSRuntime.InvokeVoidAsync("resolveEditorImages", EditorElementId, _dotNetRef);
 180            }
 181            catch (JSDisconnectedException)
 182            {
 183            }
 184            finally
 185            {
 186                _suppressEditorUpdates = false;
 187            }
 188        }
 189    }
 190
 191    protected override async Task OnAfterRenderAsync(bool firstRender)
 192    {
 193        if (ReadOnly || _editorInitialized || _disposed || _dotNetRef == null)
 194            return;
 195
 196        try
 197        {
 198            await JSRuntime.InvokeVoidAsync("initializeTipTapEditor", EditorElementId, _editorValue, _dotNetRef);
 199            await JSRuntime.InvokeVoidAsync("initializeWikiLinkAutocomplete", EditorElementId, _dotNetRef);
 200            await JSRuntime.InvokeVoidAsync("initializeImageUpload", EditorElementId, _dotNetRef);
 201            await JSRuntime.InvokeVoidAsync("resolveEditorImages", EditorElementId, _dotNetRef);
 202            _editorInitialized = true;
 203        }
 204        catch (JSDisconnectedException)
 205        {
 206        }
 207        catch (Exception ex)
 208        {
 209            Logger.LogError(ex, "Failed to initialize private notes editor {EditorId}", EditorElementId);
 210            Snackbar.Add("Failed to initialize private notes editor", Severity.Warning);
 211        }
 212    }
 213
 214    public async ValueTask DisposeAsync()
 215    {
 216        _disposed = true;
 217        DrawerCoordinator.OnChanged -= HandleDrawerCoordinatorChanged;
 218        await DestroyEditorAsync();
 219        _dotNetRef?.Dispose();
 220    }
 221
 222    private async Task DestroyEditorAsync()
 223    {
 224        if (!_editorInitialized)
 225            return;
 226
 227        try
 228        {
 229            await JSRuntime.InvokeVoidAsync("destroyTipTapEditor", EditorElementId);
 230        }
 231        catch (JSDisconnectedException)
 232        {
 233        }
 234        catch (Exception ex)
 235        {
 236            Logger.LogDebug(ex, "Failed to destroy private notes editor {EditorId}", EditorElementId);
 237        }
 238        finally
 239        {
 240            _editorInitialized = false;
 241            _showAutocomplete = false;
 242            _autocompleteSuggestions = new();
 243        }
 244    }
 245
 246    private async Task InsertImageFromToolbarAsync()
 247    {
 248        if (!_editorInitialized || _dotNetRef == null)
 249            return;
 250
 251        try
 252        {
 253            await JSRuntime.InvokeVoidAsync("triggerImageUpload", EditorElementId, _dotNetRef);
 254        }
 255        catch (JSDisconnectedException)
 256        {
 257        }
 258    }
 259
 260    [JSInvokable]
 261    public async Task OnEditorUpdate(string html)
 262    {
 263        if (_suppressEditorUpdates)
 264            return;
 265
 266        if (string.Equals(_editorValue, html, StringComparison.Ordinal))
 267            return;
 268
 269        _editorValue = html ?? string.Empty;
 270        await ValueChanged.InvokeAsync(_editorValue);
 271    }
 272
 273    [JSInvokable]
 274    public async Task OnAutocompleteTriggered(string query, double x, double y)
 275    {
 276        var normalizedQuery = query?.TrimStart() ?? string.Empty;
 277        _autocompletePosition = (x, y);
 278        _showAutocomplete = true;
 279        _autocompleteSelectedIndex = 0;
 280        var isMapAutocomplete = normalizedQuery.StartsWith("maps/", StringComparison.OrdinalIgnoreCase);
 281        string? mapSearch = null;
 282
 283        if (isMapAutocomplete)
 284        {
 285            var remainder = normalizedQuery.Substring("maps/".Length);
 286            mapSearch = string.IsNullOrWhiteSpace(remainder) ? null : remainder.Trim();
 287            _autocompleteIsExternalQuery = false;
 288            _autocompleteExternalSourceKey = null;
 289            _autocompleteQuery = mapSearch ?? string.Empty;
 290        }
 291        else
 292        {
 293            _autocompleteIsExternalQuery = TryParseExternalAutocompleteQuery(normalizedQuery, out var sourceKey, out var
 294            var mapsExternalSource = _autocompleteIsExternalQuery
 295                && string.Equals(sourceKey, "maps", StringComparison.OrdinalIgnoreCase);
 296
 297            if (mapsExternalSource)
 298            {
 299                isMapAutocomplete = true;
 300                mapSearch = string.IsNullOrWhiteSpace(remainder) ? null : remainder.Trim();
 301                _autocompleteIsExternalQuery = false;
 302                _autocompleteExternalSourceKey = null;
 303                _autocompleteQuery = mapSearch ?? string.Empty;
 304            }
 305            else
 306            {
 307                _autocompleteExternalSourceKey = _autocompleteIsExternalQuery ? sourceKey : null;
 308                _autocompleteQuery = _autocompleteIsExternalQuery ? remainder : normalizedQuery;
 309            }
 310
 311            var minLength = _autocompleteIsExternalQuery ? 0 : 3;
 312            if (!isMapAutocomplete && _autocompleteQuery.Length < minLength)
 313            {
 314                _autocompleteSuggestions = new();
 315                await InvokeAsync(StateHasChanged);
 316                return;
 317            }
 318        }
 319
 320        _autocompleteLoading = true;
 321        await InvokeAsync(StateHasChanged);
 322
 323        try
 324        {
 325            if (isMapAutocomplete)
 326            {
 327                var mapSuggestions = await MapApi.GetMapAutocompleteAsync(WorldId, mapSearch);
 328                _autocompleteSuggestions = mapSuggestions
 329                    .Select(ArticleWikiLinkAutocompleteItem.FromMapAutocomplete)
 330                    .ToList();
 331            }
 332            else if (_autocompleteIsExternalQuery)
 333            {
 334                var externalSuggestions = await ExternalLinkApiService.GetSuggestionsAsync(
 335                    WorldId,
 336                    _autocompleteExternalSourceKey ?? string.Empty,
 337                    _autocompleteQuery,
 338                    CancellationToken.None);
 339
 340                _autocompleteSuggestions = externalSuggestions.Select(ArticleWikiLinkAutocompleteItem.FromExternal).ToLi
 341            }
 342            else
 343            {
 344                var internalSuggestions = await LinkApiService.GetSuggestionsAsync(WorldId, _autocompleteQuery);
 345                _autocompleteSuggestions = internalSuggestions.Select(ArticleWikiLinkAutocompleteItem.FromInternal).ToLi
 346            }
 347        }
 348        catch (Exception ex)
 349        {
 350            Logger.LogError(ex, "Error getting private notes autocomplete suggestions");
 351            _autocompleteSuggestions = new();
 352        }
 353        finally
 354        {
 355            _autocompleteLoading = false;
 356            await InvokeAsync(StateHasChanged);
 357        }
 358    }
 359
 360    [JSInvokable]
 361    public Task OnAutocompleteHidden()
 362    {
 1363        _showAutocomplete = false;
 1364        _autocompleteSuggestions = new();
 1365        _autocompleteIsExternalQuery = false;
 1366        _autocompleteExternalSourceKey = null;
 1367        return InvokeAsync(StateHasChanged);
 368    }
 369
 370    [JSInvokable]
 371    public Task OnAutocompleteArrowDown()
 372    {
 2373        if (_autocompleteSuggestions.Any())
 374        {
 1375            _autocompleteSelectedIndex = (_autocompleteSelectedIndex + 1) % _autocompleteSuggestions.Count;
 1376            return InvokeAsync(StateHasChanged);
 377        }
 378
 1379        return Task.CompletedTask;
 380    }
 381
 382    [JSInvokable]
 383    public Task OnAutocompleteArrowUp()
 384    {
 2385        if (_autocompleteSuggestions.Any())
 386        {
 1387            _autocompleteSelectedIndex = (_autocompleteSelectedIndex - 1 + _autocompleteSuggestions.Count) % _autocomple
 1388            return InvokeAsync(StateHasChanged);
 389        }
 390
 1391        return Task.CompletedTask;
 392    }
 393
 394    [JSInvokable]
 395    public async Task OnAutocompleteEnter()
 396    {
 397        if (_autocompleteSuggestions.Any() && _autocompleteSelectedIndex < _autocompleteSuggestions.Count)
 398        {
 399            await OnAutocompleteSelect(_autocompleteSuggestions[_autocompleteSelectedIndex]);
 400        }
 401    }
 402
 403    [JSInvokable]
 404    public Task OnMapLinkClicked(string mapId, string? mapName)
 405    {
 4406        if (!Guid.TryParse(mapId, out var parsedMapId))
 407        {
 1408            return Task.CompletedTask;
 409        }
 410
 3411        if (WorldId == Guid.Empty)
 412        {
 1413            Snackbar.Add("Unable to open map: missing world context.", Severity.Warning);
 1414            return Task.CompletedTask;
 415        }
 416
 2417        _selectedMapId = parsedMapId;
 2418        _selectedMapName = string.IsNullOrWhiteSpace(mapName) ? null : mapName.Trim();
 2419        _isMapModalOpen = true;
 2420        return InvokeAsync(StateHasChanged);
 421    }
 422
 423    private Task CloseMapModalAsync()
 424    {
 1425        _isMapModalOpen = false;
 1426        _selectedMapId = Guid.Empty;
 1427        _selectedMapName = null;
 1428        return InvokeAsync(StateHasChanged);
 429    }
 430
 431    [JSInvokable]
 432    public async Task OnWikiLinkClicked(string targetArticleId)
 433    {
 434        if (!Guid.TryParse(targetArticleId, out var articleId))
 435            return;
 436
 437        try
 438        {
 439            var path = await ArticleCache.GetNavigationPathAsync(articleId);
 440            if (!string.IsNullOrWhiteSpace(path))
 441            {
 442                Navigation.NavigateTo($"/article/{path}");
 443            }
 444            else
 445            {
 446                Snackbar.Add("Article not found", Severity.Warning);
 447            }
 448        }
 449        catch (Exception ex)
 450        {
 451            Logger.LogError(ex, "Error navigating to private notes wiki link {ArticleId}", targetArticleId);
 452            Snackbar.Add("Failed to navigate to article", Severity.Error);
 453        }
 454    }
 455
 456    [JSInvokable]
 457    public Task OnBrokenLinkClicked(string targetArticleId)
 458    {
 1459        Snackbar.Add("This link points to a missing article", Severity.Warning);
 1460        return Task.CompletedTask;
 461    }
 462
 463    [JSInvokable]
 464    public async Task OnExternalLinkClicked(string source, string id, string title)
 465    {
 466        if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(id))
 467            return;
 468
 469        DrawerCoordinator.Close();
 470        if (DrawerCoordinator.Current != DrawerType.None)
 471        {
 472            return;
 473        }
 474
 475        _externalPreviewOpen = true;
 476        _externalPreviewLoading = true;
 477        _externalPreviewError = null;
 478        _externalPreviewSource = source;
 479        _externalPreviewTitle = string.IsNullOrWhiteSpace(title) ? "External Link" : title;
 480        _externalPreviewContent = null;
 481        await InvokeAsync(StateHasChanged);
 482
 483        var cacheKey = $"{source}:{id}".ToLowerInvariant();
 484        if (_externalLinkCache.TryGetValue(cacheKey, out var cached))
 485        {
 486            _externalPreviewContent = cached;
 487            _externalPreviewLoading = false;
 488            await InvokeAsync(StateHasChanged);
 489            return;
 490        }
 491
 492        try
 493        {
 494            var content = await ExternalLinkApiService.GetContentAsync(source, id, CancellationToken.None);
 495            if (content == null || string.IsNullOrWhiteSpace(content.Markdown))
 496            {
 497                _externalPreviewError = "No content available.";
 498            }
 499            else
 500            {
 501                _externalPreviewContent = content;
 502                _externalLinkCache[cacheKey] = content;
 503            }
 504        }
 505        catch (Exception ex)
 506        {
 507            Logger.LogError(ex, "Error loading external preview for private notes {Source} {Id}", source, id);
 508            _externalPreviewError = "Failed to load external content.";
 509        }
 510        finally
 511        {
 512            _externalPreviewLoading = false;
 513            await InvokeAsync(StateHasChanged);
 514        }
 515    }
 516
 517    [JSInvokable]
 518    public async Task<string?> GetArticlePath(string targetArticleId)
 519    {
 520        if (!Guid.TryParse(targetArticleId, out var articleId))
 521            return null;
 522
 523        try
 524        {
 525            return await ArticleCache.GetArticlePathAsync(articleId);
 526        }
 527        catch (Exception ex)
 528        {
 529            Logger.LogError(ex, "Error getting article path for private notes {ArticleId}", targetArticleId);
 530            return null;
 531        }
 532    }
 533
 534    [JSInvokable]
 535    public async Task<object?> GetArticleSummaryPreview(string targetArticleId)
 536    {
 537        if (!Guid.TryParse(targetArticleId, out var articleId))
 538            return null;
 539
 540        try
 541        {
 542            var preview = await SummaryApi.GetSummaryPreviewAsync(articleId);
 543            if (preview == null || !preview.HasSummary)
 544                return null;
 545
 546            return new
 547            {
 548                title = preview.Title,
 549                summary = preview.Summary,
 550                templateName = preview.TemplateName
 551            };
 552        }
 553        catch (Exception ex)
 554        {
 555            Logger.LogError(ex, "Error getting summary preview for private notes {ArticleId}", targetArticleId);
 556            return null;
 557        }
 558    }
 559
 560    [JSInvokable]
 561    public async Task<object?> OnImageUploadRequested(string fileName, string contentType, long fileSize)
 562    {
 563        if (WorldId == Guid.Empty)
 564            return null;
 565
 566        try
 567        {
 568            var request = new WorldDocumentUploadRequestDto
 569            {
 570                FileName = fileName,
 571                ContentType = contentType,
 572                FileSizeBytes = fileSize,
 573                ArticleId = null,
 574                Description = $"Inline image for {UploadContextLabel}"
 575            };
 576
 577            var response = await WorldApi.RequestDocumentUploadAsync(WorldId, request);
 578            if (response == null)
 579                return null;
 580
 581            return new
 582            {
 583                uploadUrl = response.UploadUrl,
 584                documentId = response.DocumentId.ToString()
 585            };
 586        }
 587        catch (Exception ex)
 588        {
 589            Logger.LogError(ex, "Error requesting image upload for private notes in world {WorldId}", WorldId);
 590            return null;
 591        }
 592    }
 593
 594    [JSInvokable]
 595    public async Task OnImageUploadConfirmed(string documentIdStr)
 596    {
 597        if (WorldId == Guid.Empty || !Guid.TryParse(documentIdStr, out var documentId))
 598            return;
 599
 600        try
 601        {
 602            await WorldApi.ConfirmDocumentUploadAsync(WorldId, documentId);
 603        }
 604        catch (Exception ex)
 605        {
 606            Logger.LogError(ex, "Error confirming private notes image upload {DocumentId}", documentId);
 607        }
 608    }
 609
 610    [JSInvokable]
 1611    public string GetImageProxyUrl(string documentIdStr) => $"chronicis-image:{documentIdStr}";
 612
 613    [JSInvokable]
 614    public async Task<string?> ResolveImageUrl(string documentIdStr)
 615    {
 616        if (!Guid.TryParse(documentIdStr, out var documentId))
 617            return null;
 618
 619        try
 620        {
 621            var result = await WorldApi.DownloadDocumentAsync(documentId);
 622            return result?.DownloadUrl;
 623        }
 624        catch (Exception ex)
 625        {
 626            Logger.LogError(ex, "Error resolving private notes image {DocumentId}", documentId);
 627            return null;
 628        }
 629    }
 630
 631    [JSInvokable]
 632    public Task OnImageUploadStarted(string fileName)
 633    {
 1634        Snackbar.Add($"Uploading {fileName}...", Severity.Info);
 1635        return Task.CompletedTask;
 636    }
 637
 638    [JSInvokable]
 639    public Task OnImageUploadError(string message)
 640    {
 1641        Snackbar.Add(message, Severity.Error);
 1642        return Task.CompletedTask;
 643    }
 644
 645    private Task OnAutocompleteIndexChanged(int index)
 646    {
 1647        _autocompleteSelectedIndex = index;
 1648        StateHasChanged();
 1649        return Task.CompletedTask;
 650    }
 651
 652    private async Task OnAutocompleteSelect(ArticleWikiLinkAutocompleteItem suggestion)
 653    {
 654        try
 655        {
 656            if (suggestion.IsCategory && !string.IsNullOrEmpty(suggestion.CategoryKey))
 657            {
 658                await JSRuntime.InvokeVoidAsync("updateAutocompleteText", EditorElementId, $"{suggestion.Source}/{sugges
 659                return;
 660            }
 661
 662            if (suggestion.MapId.HasValue)
 663            {
 664                await JSRuntime.InvokeVoidAsync(
 665                    "insertMapLinkToken",
 666                    EditorElementId,
 667                    suggestion.MapId.Value.ToString(),
 668                    suggestion.Title);
 669            }
 670            else if (suggestion.IsExternal)
 671            {
 672                if (string.IsNullOrWhiteSpace(suggestion.Source) || string.IsNullOrWhiteSpace(suggestion.ExternalId))
 673                {
 674                    Logger.LogWarning("External suggestion missing source or id");
 675                    return;
 676                }
 677
 678                await JSRuntime.InvokeVoidAsync("insertExternalLinkToken", EditorElementId, suggestion.Source, suggestio
 679            }
 680            else
 681            {
 682                if (!suggestion.ArticleId.HasValue)
 683                {
 684                    Logger.LogWarning("Internal suggestion missing article id");
 685                    return;
 686                }
 687
 688                var displayText = !string.IsNullOrWhiteSpace(suggestion.MatchedAlias)
 689                    ? $"{suggestion.MatchedAlias} → {suggestion.Title}"
 690                    : suggestion.Title;
 691
 692                await JSRuntime.InvokeVoidAsync("insertWikiLink", EditorElementId, suggestion.ArticleId.Value.ToString()
 693            }
 694
 695            _showAutocomplete = false;
 696            _autocompleteSuggestions = new();
 697            StateHasChanged();
 698        }
 699        catch (Exception ex)
 700        {
 701            Logger.LogError(ex, "Error inserting private notes link");
 702            Snackbar.Add("Failed to insert link", Severity.Error);
 703        }
 704    }
 705
 706    private async Task OnAutocompleteCreate(string articleName)
 707    {
 708        if (_autocompleteIsExternalQuery || string.IsNullOrWhiteSpace(articleName) || WorldId == Guid.Empty)
 709            return;
 710
 711        try
 712        {
 713            var created = await WikiLinkService.CreateArticleFromAutocompleteAsync(articleName, WorldId);
 714            if (created == null)
 715            {
 716                Snackbar.Add("Failed to create article", Severity.Error);
 717                return;
 718            }
 719
 720            await JSRuntime.InvokeVoidAsync("insertWikiLink", EditorElementId, created.Id.ToString(), created.Title);
 721            _showAutocomplete = false;
 722            _autocompleteSuggestions = new();
 723            StateHasChanged();
 724            Snackbar.Add($"Created and linked '{articleName}'", Severity.Success);
 725        }
 726        catch (Exception ex)
 727        {
 728            Logger.LogError(ex, "Error creating article from private notes autocomplete");
 729            Snackbar.Add($"Failed to create article: {ex.Message}", Severity.Error);
 730        }
 731    }
 732
 733    private void CloseExternalPreview()
 734    {
 1735        _externalPreviewOpen = false;
 1736        StateHasChanged();
 1737    }
 738
 739    private void HandleDrawerCoordinatorChanged()
 740    {
 8741        if (!_externalPreviewOpen || DrawerCoordinator.Current == DrawerType.None)
 742        {
 6743            return;
 744        }
 745
 2746        _externalPreviewOpen = false;
 2747        _ = InvokeAsync(StateHasChanged);
 2748    }
 749
 750    private static bool TryParseExternalAutocompleteQuery(string query, out string sourceKey, out string remainder)
 751    {
 9752        sourceKey = string.Empty;
 9753        remainder = string.Empty;
 754
 9755        if (string.IsNullOrWhiteSpace(query))
 1756            return false;
 757
 8758        var slashIndex = query.IndexOf('/');
 8759        if (slashIndex <= 0)
 5760            return false;
 761
 3762        sourceKey = query[..slashIndex].Trim().ToLowerInvariant();
 3763        remainder = query[(slashIndex + 1)..];
 3764        return !string.IsNullOrWhiteSpace(sourceKey);
 765    }
 766}