Skip to content

Commit f07a4bb

Browse files
committed
Add prep mode contextual notes (#34)
1 parent 2d0ebca commit f07a4bb

27 files changed

Lines changed: 639 additions & 0 deletions

‎src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using PrompterOne.Core.Services.Rsvp;
1616
using PrompterOne.Core.Services.Streaming;
1717
using PrompterOne.Core.Services.Workspace;
18+
using PrompterOne.Shared.Learn.Services;
1819
using PrompterOne.Shared.Localization;
1920
using PrompterOne.Shared.Services.Diagnostics;
2021
using PrompterOne.Shared.Services.Editor;
@@ -108,6 +109,7 @@ public static IServiceCollection AddPrompterOneShared(
108109
services.AddScoped<BrowserMediaCaptureCapabilitiesService>();
109110
services.AddScoped<CameraPreviewInterop>();
110111
services.AddScoped<LearnRsvpLayoutInterop>();
112+
services.AddScoped<LearnPrepNotesStore>();
111113
services.AddScoped<MicrophoneLevelInterop>();
112114
services.AddScoped<TeleprompterReaderInterop>();
113115
services.AddScoped<KineticReaderInterop>();

‎src/PrompterOne.Shared/Contracts/UiTestIds.Learn.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ public static class Learn
1111
public const string FocusRow = "learn-focus-row";
1212
public const string LoopToggle = "learn-loop-toggle";
1313
public const string NextPhrase = "learn-next-phrase";
14+
public const string NotesEmpty = "learn-notes-empty";
15+
public const string NotesList = "learn-notes-list";
16+
public const string NotesPanel = "learn-notes-panel";
17+
public const string NotesSave = "learn-notes-save";
18+
public const string NotesSection = "learn-notes-section";
19+
public const string NotesTextarea = "learn-notes-textarea";
1420
public const string OrpLine = "learn-orp-line";
1521
public const string Page = "learn-page";
1622
public const string PauseIcon = "learn-pause-icon";
@@ -30,5 +36,7 @@ public static class Learn
3036
public const string WordOrp = "learn-word-orp";
3137
public const string WordShell = "learn-word-shell";
3238
public const string WordTrailing = "learn-word-trailing";
39+
40+
public static string NoteItem(string id) => $"learn-note-{id}";
3341
}
3442
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Microsoft.AspNetCore.Components;
2+
using PrompterOne.Shared.Learn.Services;
3+
using PrompterOne.Shared.Localization;
4+
5+
namespace PrompterOne.Shared.Pages;
6+
7+
public partial class LearnPage
8+
{
9+
private sealed record LearnPrepNoteContext(
10+
string ScriptKey,
11+
string SectionKey,
12+
string SectionLabel,
13+
string AnchorWord,
14+
int WordIndex);
15+
16+
private IReadOnlyList<LearnPrepNote> CurrentSectionPrepNotes =>
17+
_prepNotes
18+
.Where(note => string.Equals(note.SectionKey, CurrentPrepNoteContext.SectionKey, StringComparison.Ordinal))
19+
.OrderBy(note => note.CreatedAt)
20+
.ToArray();
21+
22+
private LearnPrepNoteContext CurrentPrepNoteContext => BuildCurrentPrepNoteContext();
23+
24+
private string CurrentPrepNotesSectionLabel =>
25+
Format(UiTextKey.LearnNotesSectionFormat, CurrentPrepNoteContext.SectionLabel);
26+
27+
private bool CanAddPrepNote => !string.IsNullOrWhiteSpace(_prepNoteDraft);
28+
29+
private async Task LoadPrepNotesAsync()
30+
{
31+
var scriptKey = ResolvePrepNotesScriptKey();
32+
_prepNotes = string.IsNullOrWhiteSpace(scriptKey)
33+
? []
34+
: await PrepNotesStore.LoadForScriptAsync(scriptKey);
35+
}
36+
37+
private void HandlePrepNoteDraftChanged(ChangeEventArgs args) =>
38+
_prepNoteDraft = args.Value?.ToString() ?? string.Empty;
39+
40+
private async Task AddPrepNoteAsync()
41+
{
42+
if (!CanAddPrepNote)
43+
{
44+
return;
45+
}
46+
47+
var context = CurrentPrepNoteContext;
48+
var note = await PrepNotesStore.AddAsync(new LearnPrepNoteDraft(
49+
context.ScriptKey,
50+
context.SectionKey,
51+
context.SectionLabel,
52+
context.AnchorWord,
53+
context.WordIndex,
54+
_prepNoteDraft));
55+
56+
if (note is null)
57+
{
58+
return;
59+
}
60+
61+
_prepNoteDraft = string.Empty;
62+
_prepNotes = _prepNotes.Append(note).ToArray();
63+
}
64+
65+
private LearnPrepNoteContext BuildCurrentPrepNoteContext()
66+
{
67+
var scriptKey = ResolvePrepNotesScriptKey();
68+
if (_timeline.Count == 0)
69+
{
70+
return new LearnPrepNoteContext(
71+
scriptKey,
72+
"empty",
73+
Text(UiTextKey.LearnReadyWord),
74+
Text(UiTextKey.LearnReadyWord),
75+
0);
76+
}
77+
78+
var safeIndex = Math.Clamp(_currentIndex, 0, _timeline.Count - 1);
79+
var entry = _timeline[safeIndex];
80+
var sentenceRange = ResolveSentenceRange(_timeline, safeIndex);
81+
var sectionLabel = BuildSectionLabel(sentenceRange.StartIndex, sentenceRange.EndIndex);
82+
var anchorWord = NormalizeDisplayWord(entry.Word);
83+
return new LearnPrepNoteContext(
84+
scriptKey,
85+
$"{sentenceRange.StartIndex}-{sentenceRange.EndIndex}",
86+
sectionLabel,
87+
string.IsNullOrWhiteSpace(anchorWord) ? entry.Word : anchorWord,
88+
Math.Max(0, entry.WordIndex));
89+
}
90+
91+
private string BuildSectionLabel(int startIndex, int endIndex)
92+
{
93+
var words = BuildDisplayContextWords(_timeline, startIndex, endIndex + 1);
94+
var label = words.Count == 0 ? _screenSubtitle : string.Join(' ', words);
95+
if (string.IsNullOrWhiteSpace(label))
96+
{
97+
label = _screenTitle;
98+
}
99+
100+
return string.IsNullOrWhiteSpace(label)
101+
? Text(UiTextKey.LearnReadyWord)
102+
: label;
103+
}
104+
105+
private string ResolvePrepNotesScriptKey()
106+
{
107+
if (!string.IsNullOrWhiteSpace(SessionService.State.ScriptId))
108+
{
109+
return SessionService.State.ScriptId;
110+
}
111+
112+
if (!string.IsNullOrWhiteSpace(SessionService.State.DocumentName))
113+
{
114+
return SessionService.State.DocumentName;
115+
}
116+
117+
return SessionService.State.Title;
118+
}
119+
}

‎src/PrompterOne.Shared/Learn/Pages/LearnPage.razor‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,46 @@
7575
<div class="rsvp-pause-track">
7676
<div id="@UiDomIds.Learn.PauseFill" class="rsvp-pause-fill"></div>
7777
</div>
78+
79+
<aside class="rsvp-notes"
80+
data-test="@UiTestIds.Learn.NotesPanel"
81+
@onkeydown:stopPropagation="true">
82+
<div class="rsvp-notes-head">
83+
<span class="rsvp-notes-title">@Text(UiTextKey.LearnNotesTitle)</span>
84+
<span class="rsvp-notes-section" data-test="@UiTestIds.Learn.NotesSection">@CurrentPrepNotesSectionLabel</span>
85+
</div>
86+
<textarea class="rsvp-notes-input"
87+
rows="3"
88+
value="@_prepNoteDraft"
89+
placeholder="@Text(UiTextKey.LearnNotesPlaceholder)"
90+
aria-label="@Text(UiTextKey.LearnNotesTitle)"
91+
@oninput="HandlePrepNoteDraftChanged"
92+
data-test="@UiTestIds.Learn.NotesTextarea"></textarea>
93+
<button class="rsvp-notes-save"
94+
type="button"
95+
disabled="@(!CanAddPrepNote)"
96+
@onclick="AddPrepNoteAsync"
97+
data-test="@UiTestIds.Learn.NotesSave">@Text(UiTextKey.LearnNotesSave)</button>
98+
<div class="rsvp-notes-list" data-test="@UiTestIds.Learn.NotesList">
99+
@{
100+
var currentNotes = CurrentSectionPrepNotes;
101+
}
102+
@if (currentNotes.Count == 0)
103+
{
104+
<p class="rsvp-notes-empty" data-test="@UiTestIds.Learn.NotesEmpty">@Text(UiTextKey.LearnNotesEmpty)</p>
105+
}
106+
else
107+
{
108+
@foreach (var note in currentNotes)
109+
{
110+
<article class="rsvp-note" data-test="@UiTestIds.Learn.NoteItem(note.Id)">
111+
<span class="rsvp-note-anchor">@note.AnchorWord</span>
112+
<p>@note.Text</p>
113+
</article>
114+
}
115+
}
116+
</div>
117+
</aside>
78118
</div>
79119

80120
<div class="rsvp-bottom">

‎src/PrompterOne.Shared/Learn/Pages/LearnPage.razor.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using PrompterOne.Core.Models.Workspace;
44
using PrompterOne.Core.Services.Rsvp;
55
using PrompterOne.Shared.Contracts;
6+
using PrompterOne.Shared.Learn.Services;
67
using PrompterOne.Shared.Localization;
78
using PrompterOne.Shared.Services;
89
using PrompterOne.Shared.Services.Diagnostics;
@@ -33,6 +34,7 @@ public partial class LearnPage : IAsyncDisposable
3334
[Inject] private IScriptSessionService SessionService { get; set; } = null!;
3435
[Inject] private AppShellService Shell { get; set; } = null!;
3536
[Inject] private LearnRsvpLayoutInterop LearnRsvpLayoutInterop { get; set; } = null!;
37+
[Inject] private LearnPrepNotesStore PrepNotesStore { get; set; } = null!;
3638
[Inject] private RsvpTextProcessor TextProcessor { get; set; } = null!;
3739

3840
[Parameter]
@@ -62,7 +64,9 @@ public partial class LearnPage : IAsyncDisposable
6264
private string _currentWordLeading = string.Empty;
6365
private string _currentWordOrp = string.Empty;
6466
private string _currentWordTrailing = string.Empty;
67+
private string _prepNoteDraft = string.Empty;
6568
private IReadOnlyList<string> _leftContextWords = [];
69+
private IReadOnlyList<LearnPrepNote> _prepNotes = [];
6670
private IReadOnlyList<string> _rightContextWords = [];
6771
private IReadOnlyList<RsvpTimelineEntry> _timeline = [];
6872

@@ -88,6 +92,7 @@ await Diagnostics.RunAsync(
8892
await Bootstrapper.EnsureReadyAsync();
8993
await EnsureSessionLoadedAsync();
9094
PopulateLearnState();
95+
await LoadPrepNotesAsync();
9196
StateHasChanged();
9297
});
9398
return;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using PrompterOne.Core.Abstractions;
2+
using PrompterOne.Shared.Storage;
3+
4+
namespace PrompterOne.Shared.Learn.Services;
5+
6+
public sealed class LearnPrepNotesStore(IUserSettingsStore settingsStore)
7+
{
8+
private const int MaxNoteLength = 2_000;
9+
10+
private readonly IUserSettingsStore _settingsStore = settingsStore;
11+
12+
public async Task<IReadOnlyList<LearnPrepNote>> LoadForScriptAsync(
13+
string scriptKey,
14+
CancellationToken cancellationToken = default)
15+
{
16+
if (string.IsNullOrWhiteSpace(scriptKey))
17+
{
18+
return [];
19+
}
20+
21+
var snapshot = await LoadSnapshotAsync(cancellationToken);
22+
return snapshot.Notes
23+
.Where(note => string.Equals(note.ScriptKey, scriptKey, StringComparison.Ordinal))
24+
.OrderBy(note => note.WordIndex)
25+
.ThenBy(note => note.CreatedAt)
26+
.ToArray();
27+
}
28+
29+
public async Task<LearnPrepNote?> AddAsync(
30+
LearnPrepNoteDraft draft,
31+
CancellationToken cancellationToken = default)
32+
{
33+
var text = NormalizeText(draft.Text);
34+
if (string.IsNullOrWhiteSpace(draft.ScriptKey) || string.IsNullOrWhiteSpace(text))
35+
{
36+
return null;
37+
}
38+
39+
var now = DateTimeOffset.UtcNow;
40+
var note = new LearnPrepNote(
41+
Id: Guid.NewGuid().ToString("N"),
42+
ScriptKey: draft.ScriptKey,
43+
SectionKey: draft.SectionKey,
44+
SectionLabel: draft.SectionLabel,
45+
AnchorWord: draft.AnchorWord,
46+
WordIndex: draft.WordIndex,
47+
Text: text,
48+
CreatedAt: now);
49+
50+
var snapshot = await LoadSnapshotAsync(cancellationToken);
51+
var notes = snapshot.Notes.ToList();
52+
notes.Add(note);
53+
await SaveSnapshotAsync(new LearnPrepNotesSnapshot(notes), cancellationToken);
54+
return note;
55+
}
56+
57+
private async Task<LearnPrepNotesSnapshot> LoadSnapshotAsync(CancellationToken cancellationToken)
58+
{
59+
var snapshot = await _settingsStore.LoadAsync<LearnPrepNotesSnapshot>(
60+
BrowserAppSettingsKeys.LearnPrepNotes,
61+
cancellationToken);
62+
63+
return snapshot?.Normalize() ?? LearnPrepNotesSnapshot.Empty;
64+
}
65+
66+
private Task SaveSnapshotAsync(LearnPrepNotesSnapshot snapshot, CancellationToken cancellationToken) =>
67+
_settingsStore.SaveAsync(BrowserAppSettingsKeys.LearnPrepNotes, snapshot.Normalize(), cancellationToken);
68+
69+
private static string NormalizeText(string? text)
70+
{
71+
var normalized = string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim();
72+
return normalized.Length <= MaxNoteLength
73+
? normalized
74+
: normalized[..MaxNoteLength];
75+
}
76+
}
77+
78+
public sealed record LearnPrepNote(
79+
string Id,
80+
string ScriptKey,
81+
string SectionKey,
82+
string SectionLabel,
83+
string AnchorWord,
84+
int WordIndex,
85+
string Text,
86+
DateTimeOffset CreatedAt);
87+
88+
public sealed record LearnPrepNoteDraft(
89+
string ScriptKey,
90+
string SectionKey,
91+
string SectionLabel,
92+
string AnchorWord,
93+
int WordIndex,
94+
string Text);
95+
96+
public sealed record LearnPrepNotesSnapshot(IReadOnlyList<LearnPrepNote> Notes)
97+
{
98+
public static LearnPrepNotesSnapshot Empty { get; } = new([]);
99+
100+
public LearnPrepNotesSnapshot Normalize() =>
101+
this with { Notes = Notes?.Where(IsValid).ToArray() ?? [] };
102+
103+
private static bool IsValid(LearnPrepNote? note) =>
104+
note is not null &&
105+
!string.IsNullOrWhiteSpace(note.Id) &&
106+
!string.IsNullOrWhiteSpace(note.ScriptKey) &&
107+
!string.IsNullOrWhiteSpace(note.Text);
108+
}

‎src/PrompterOne.Shared/Localization/SharedResource.de.resx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,21 @@
17431743
<data name="LearnProgressFormat" xml:space="preserve">
17441744
<value>{0}/{1} · {2}:{3:00}</value>
17451745
</data>
1746+
<data name="LearnNotesTitle" xml:space="preserve">
1747+
<value>Vorbereitungsnotizen</value>
1748+
</data>
1749+
<data name="LearnNotesSectionFormat" xml:space="preserve">
1750+
<value>Abschnitt: {0}</value>
1751+
</data>
1752+
<data name="LearnNotesPlaceholder" xml:space="preserve">
1753+
<value>Notiz für diesen Abschnitt hinzufügen...</value>
1754+
</data>
1755+
<data name="LearnNotesSave" xml:space="preserve">
1756+
<value>Notiz hinzufügen</value>
1757+
</data>
1758+
<data name="LearnNotesEmpty" xml:space="preserve">
1759+
<value>Noch keine Vorbereitungsnotizen.</value>
1760+
</data>
17461761
<data name="RoutesNotFoundTitle" xml:space="preserve">
17471762
<value>Bildschirm nicht gefunden</value>
17481763
</data>

‎src/PrompterOne.Shared/Localization/SharedResource.es.resx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,21 @@
17431743
<data name="LearnProgressFormat" xml:space="preserve">
17441744
<value>{0}/{1} · {2}:{3:00}</value>
17451745
</data>
1746+
<data name="LearnNotesTitle" xml:space="preserve">
1747+
<value>Notas de preparación</value>
1748+
</data>
1749+
<data name="LearnNotesSectionFormat" xml:space="preserve">
1750+
<value>Sección: {0}</value>
1751+
</data>
1752+
<data name="LearnNotesPlaceholder" xml:space="preserve">
1753+
<value>Añade una nota para esta sección...</value>
1754+
</data>
1755+
<data name="LearnNotesSave" xml:space="preserve">
1756+
<value>Añadir nota</value>
1757+
</data>
1758+
<data name="LearnNotesEmpty" xml:space="preserve">
1759+
<value>Aún no hay notas de preparación.</value>
1760+
</data>
17461761
<data name="RoutesNotFoundTitle" xml:space="preserve">
17471762
<value>Pantalla no encontrada</value>
17481763
</data>

0 commit comments

Comments
 (0)