@page "/print" @attribute [Authorize] @implements IAsyncDisposable @using Core.Printing @inject INotesService NotesService @inject INotePrintService NotePrintService @inject IPrintPresetService PrintPresetService @inject IConfiguration Configuration @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IJSRuntime JSRuntime @inject NavigationManager NavigationManager @inject MarkdownTablePasteService MarkdownTablePasteService
@if (_isLoading) { } @foreach (var preset in _presets) { @preset.Name } New Save Delete Students Teams Events @if (_entityType == PrintEntityType.Student) { Any Officers only Non-officers only } else if (_entityType == PrintEntityType.Team) { } else { @foreach (var format in Enum.GetValues()) { @format } Any Regional only Non-regional only } Insert token Preview Print @if (IsDirty()) { Unsaved changes } @if (!_previewStale && _pages.Count > 0) { @_pages.Count page@(_pages.Count == 1 ? "" : "s") } else if (!_previewStale && _didPreview) { No matches } Template (Markdown)
Template preview Markdown only — tokens are not merged here. @if (string.IsNullOrWhiteSpace(_templateMarkdown)) { The template will preview here as you type. } else {
@((MarkupString)MarkdownHelper.ToHtml(_templateMarkdown))
}
@code { private const string EditorElementId = "page-printer-editor"; private static readonly string[] HiddenEditorIcons = ["preview", "side-by-side", "fullscreen"]; private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed; private bool _isLoading = true; private bool _isBusy; private bool _previewStale = true; private bool _didPreview; private bool _pasteInitialized; private int _editorGeneration; private List _presets = []; private List _importedTokenNames = []; private IReadOnlyList _pages = []; private IDialogReference? _previewDialog; private IDisposable? _navigationRegistration; private int? _selectedPresetId; private string _templateMarkdown = string.Empty; private PrintEntityType _entityType = PrintEntityType.Student; private int? _grade; private int? _tsaYear; private string _officerChoice = "any"; private string? _teamIdentifierContains; private string? _eventNameContains; private EventFormat? _eventFormat; private string _regionalChoice = "any"; private bool _newPagePerRecord = true; private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt; private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; private string _snapshotMarkdown = string.Empty; private PrintEntityType _snapshotEntityType = PrintEntityType.Student; private string _snapshotFiltersJson = string.Empty; private string PrintPageStyle => $"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};"; private bool CanPreview => !string.IsNullOrWhiteSpace(_templateMarkdown); protected override void OnInitialized() { _cancellationTokenSource = new CancellationTokenSource(); CaptureSnapshot(); _navigationRegistration = NavigationManager.RegisterLocationChangingHandler(OnLocationChanging); } protected override async Task OnInitializedAsync() { await LoadAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (_pasteInitialized || _isDisposed) return; await Task.Delay(150); if (_isDisposed) return; await MarkdownTablePasteService.InitializeAsync(EditorElementId); _pasteInitialized = true; } private async Task LoadAsync() { if (_isDisposed) return; _isLoading = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await RefreshImportedTokenNamesAsync(token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } finally { if (!_isDisposed) _isLoading = false; } } private async Task MarkStaleAsync() { _previewStale = true; _pages = []; await ClosePreviewDialogAsync(); } private void MarkStale() => _ = MarkStaleAsync(); private async Task ClosePreviewDialogAsync() { var dialog = _previewDialog; _previewDialog = null; if (dialog is null) return; try { dialog.Close(); } catch (InvalidOperationException) { } } private async Task OnEntityTypeChanged(PrintEntityType value) { _entityType = value; await MarkStaleAsync(); } private void OnMarkdownChanged(string? value) { _templateMarkdown = value ?? string.Empty; MarkStale(); } private void OnGradeChanged(int? value) { _grade = value; MarkStale(); } private void OnTsaYearChanged(int? value) { _tsaYear = value; MarkStale(); } private void OnOfficerChanged(string value) { _officerChoice = value; MarkStale(); } private void OnTeamIdentifierChanged(string? value) { _teamIdentifierContains = value; MarkStale(); } private void OnEventNameChanged(string? value) { _eventNameContains = value; MarkStale(); } private void OnEventFormatChanged(EventFormat? value) { _eventFormat = value; MarkStale(); } private void OnRegionalChanged(string value) { _regionalChoice = value; MarkStale(); } private void OnNewPagePerRecordChanged(bool value) { _newPagePerRecord = value; } private void OnFontSizeChanged(int value) { _fontSizePt = Math.Clamp(value, PrintPresetFilters.MinFontSizePt, PrintPresetFilters.MaxFontSizePt); } private void OnAnswerSpaceLinesChanged(int value) { _answerSpaceLines = Math.Clamp(value, PrintPresetFilters.MinAnswerSpaceLines, PrintPresetFilters.MaxAnswerSpaceLines); } private async Task RefreshImportedTokenNamesAsync(CancellationToken token) { var indexFields = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration); var discovered = await NotesService.GetImportedFieldNamesAsync(token); _importedTokenNames = [ .. indexFields .Concat(discovered) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) ]; } private async Task FlushEditorAsync() { var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId); if (value is not null && value != _templateMarkdown) { _templateMarkdown = value; await MarkStaleAsync(); } } private bool IsDirty() { return !string.Equals(_templateMarkdown, _snapshotMarkdown, StringComparison.Ordinal) || _entityType != _snapshotEntityType || !string.Equals(BuildFilters().ToJson(), _snapshotFiltersJson, StringComparison.Ordinal); } private void CaptureSnapshot() { _snapshotMarkdown = _templateMarkdown; _snapshotEntityType = _entityType; _snapshotFiltersJson = BuildFilters().ToJson(); } private async Task ConfirmDiscardIfDirtyAsync() { await FlushEditorAsync(); if (!IsDirty()) return true; var confirmed = await DialogService.ShowMessageBox( "Unsaved changes", "Discard unsaved template or filter changes?", yesText: "Discard", cancelText: "Stay"); return confirmed == true && !_isDisposed; } private async ValueTask OnLocationChanging(LocationChangingContext context) { if (_isDisposed) return; if (!await ConfirmDiscardIfDirtyAsync()) context.PreventNavigation(); } private async Task OnPresetSelected(int? id) { if (id == _selectedPresetId) return; if (!await ConfirmDiscardIfDirtyAsync()) return; _selectedPresetId = id; if (!id.HasValue) { await MarkStaleAsync(); return; } var preset = _presets.FirstOrDefault(p => p.Id == id.Value); if (preset is null) return; ApplyPreset(preset); CaptureSnapshot(); await MarkStaleAsync(); } private async Task NewTemplate() { if (_isDisposed || !await ConfirmDiscardIfDirtyAsync()) return; _selectedPresetId = null; _templateMarkdown = string.Empty; _entityType = PrintEntityType.Student; _grade = null; _tsaYear = null; _officerChoice = "any"; _teamIdentifierContains = null; _eventNameContains = null; _eventFormat = null; _regionalChoice = "any"; _newPagePerRecord = true; _fontSizePt = PrintPresetFilters.DefaultFontSizePt; _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; _editorGeneration++; _pasteInitialized = false; CaptureSnapshot(); await MarkStaleAsync(); } private void ApplyPreset(PrintPreset preset) { _entityType = preset.EntityType; _templateMarkdown = preset.TemplateMarkdown ?? string.Empty; var filters = PrintPresetFilters.FromJson(preset.FiltersJson); _grade = filters.Grade; _tsaYear = filters.TsaYear; _officerChoice = PrintPresetFilters.ToTriState(filters.IsOfficer); _teamIdentifierContains = filters.TeamIdentifierContains; _eventNameContains = filters.EventNameContains; _eventFormat = filters.EventFormat; _regionalChoice = PrintPresetFilters.ToTriState(filters.RegionalOnly); _newPagePerRecord = filters.NewPagePerRecord; _fontSizePt = filters.FontSizePt; _answerSpaceLines = filters.AnswerSpaceLines; _editorGeneration++; _pasteInitialized = false; } private PrintPresetFilters BuildFilters() => new() { Grade = _entityType == PrintEntityType.Student ? _grade : null, TsaYear = _entityType == PrintEntityType.Student ? _tsaYear : null, IsOfficer = _entityType == PrintEntityType.Student ? PrintPresetFilters.FromTriState(_officerChoice) : null, TeamIdentifierContains = _entityType == PrintEntityType.Team ? _teamIdentifierContains : null, EventNameContains = _entityType == PrintEntityType.Event ? _eventNameContains : null, EventFormat = _entityType == PrintEntityType.Event ? _eventFormat : null, RegionalOnly = _entityType == PrintEntityType.Event ? PrintPresetFilters.FromTriState(_regionalChoice) : null, NewPagePerRecord = _newPagePerRecord, FontSizePt = _fontSizePt, AnswerSpaceLines = _answerSpaceLines }; private async Task OpenTokenDialog() { if (_isDisposed) return; var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await RefreshImportedTokenNamesAsync(token); var parameters = new DialogParameters { { x => x.EntityType, _entityType }, { x => x.ImportedFieldNames, _importedTokenNames }, { x => x.OnInsert, EventCallback.Factory.Create(this, InsertToken) } }; var options = new DialogOptions { MaxWidth = MaxWidth.Large, FullWidth = true, CloseButton = true }; await DialogService.ShowAsync("Insert token", parameters, options); } private async Task InsertToken(string tokenName) { if (_isDisposed) return; await FlushEditorAsync(); var wrapped = "{{" + tokenName + "}}"; var inserted = await MarkdownTablePasteService.InsertAtCursorAsync(EditorElementId, wrapped); if (!inserted) { _templateMarkdown += wrapped; await MarkdownTablePasteService.SetValueAsync(EditorElementId, _templateMarkdown); } else { var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId); if (value is not null) _templateMarkdown = value; } await MarkStaleAsync(); } private async Task Preview() { if (_isDisposed) return; await FlushEditorAsync(); if (!CanPreview) { if (!_isDisposed) Snackbar.Add("Write a template before previewing.", Severity.Warning); return; } if (!_previewStale && _didPreview) { await OpenPreviewDialogAsync(); return; } _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await RefreshImportedTokenNamesAsync(token); _pages = await NotePrintService.PreviewAsync( new NotePrintRequest { EntityType = _entityType, TemplateMarkdown = _templateMarkdown, Filters = BuildFilters(), ImportedFieldCatalog = _importedTokenNames }, token); _previewStale = false; _didPreview = true; if (!_isDisposed) await OpenPreviewDialogAsync(); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Preview failed: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task OpenPreviewDialogAsync() { await ClosePreviewDialogAsync(); var parameters = new DialogParameters { { x => x.Pages, _pages }, { x => x.NewPagePerRecord, _newPagePerRecord }, { x => x.PrintPageStyle, PrintPageStyle }, { x => x.OnPrint, EventCallback.Factory.Create(this, Print) } }; var options = new DialogOptions { MaxWidth = MaxWidth.ExtraLarge, FullWidth = true, CloseButton = true }; var dialog = await DialogService.ShowAsync("Print preview", parameters, options); _previewDialog = dialog; _ = TrackPreviewDialog(dialog); } private async Task TrackPreviewDialog(IDialogReference dialog) { try { await dialog.Result; } catch (TaskCanceledException) { } finally { if (_previewDialog == dialog) _previewDialog = null; } } private async Task Print() { if (_isDisposed || _previewStale || _pages.Count == 0) return; try { await JSRuntime.InvokeVoidAsync("window.print"); } catch (JSDisconnectedException) { } catch (TaskCanceledException) { } } private async Task SavePreset() { if (_isDisposed) return; await FlushEditorAsync(); if (!CanPreview) { if (!_isDisposed) Snackbar.Add("Write a template before saving.", Severity.Warning); return; } if (_selectedPresetId.HasValue) { await UpdateSelectedPresetAsync(); return; } var name = await PromptForPresetNameAsync(); if (string.IsNullOrWhiteSpace(name) || _isDisposed) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; if (await PrintPresetService.NameExistsAsync(name, null, token)) { Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning); return; } var created = await PrintPresetService.CreateAsync( new PrintPreset { Name = name, TemplateMarkdown = _templateMarkdown, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = created.Id; CaptureSnapshot(); if (!_isDisposed) Snackbar.Add($"Saved print preset '{name}'.", Severity.Success); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not save: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task PromptForPresetNameAsync() { var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; var dialog = await DialogService.ShowAsync("Save print preset", options); var result = await dialog.Result; if (result is null || result.Canceled || result.Data is not string name) return null; return name.Trim(); } private async Task UpdateSelectedPresetAsync() { if (_isDisposed || !_selectedPresetId.HasValue) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value); if (existing is null) return; await PrintPresetService.UpdateAsync( new PrintPreset { Id = existing.Id, Name = existing.Name, TemplateMarkdown = _templateMarkdown, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; CaptureSnapshot(); if (!_isDisposed) Snackbar.Add($"Saved print preset '{existing.Name}'.", Severity.Success); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not save: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task DeletePreset() { if (_isDisposed || !_selectedPresetId.HasValue) return; var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value); if (existing is null) return; var confirmed = await DialogService.ShowMessageBox( "Delete print preset", (MarkupString)$"Delete {existing.Name}? This cannot be undone.", yesText: "Delete", cancelText: "Cancel"); if (confirmed != true || _isDisposed) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await PrintPresetService.DeleteAsync(existing.Id, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = null; CaptureSnapshot(); if (!_isDisposed) Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not delete: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } public async ValueTask DisposeAsync() { if (!_isDisposed) { _isDisposed = true; _navigationRegistration?.Dispose(); _navigationRegistration = null; await ClosePreviewDialogAsync(); _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; } await ValueTask.CompletedTask; } }