-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMainPage.xaml.cs
More file actions
299 lines (263 loc) · 8.26 KB
/
Copy pathMainPage.xaml.cs
File metadata and controls
299 lines (263 loc) · 8.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using NotesApp.Client.Ai;
using NotesApp.Core.Text;
using NotesApp.Client.Theming;
using NotesApp.Client.ViewModels;
namespace NotesApp.Client;
public partial class MainPage : ContentPage
{
private readonly NotesViewModel _viewModel;
private readonly AiService _aiService;
// The editor <-> C# bridge is polled: JS queues outbound messages that we
// collect via window.__drain(), and we push inbound messages via
// window.__inbox(). We use EvaluateJavaScriptAsync because the HybridWebView
// message bridge (window.HybridWebView) isn't injected reliably here.
private IDispatcherTimer? _pollTimer;
private bool _draining;
private bool _editorReady;
private string _pendingHtml = string.Empty;
public MainPage(NotesViewModel viewModel, AiService aiService)
{
InitializeComponent();
_viewModel = viewModel;
_aiService = aiService;
BindingContext = viewModel;
_viewModel.EditorContentRequested += OnEditorContentRequested;
_viewModel.EditorContentFetcher = FetchEditorContentAsync;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
ApplySidebarState();
}
protected override async void OnAppearing()
{
base.OnAppearing();
// Poll the editor for outbound messages. 100ms keeps the initial content
// load and ghost-text/autosave round-trips feeling immediate; an empty
// drain is cheap, so the extra polls cost little on a desktop app.
if (_pollTimer is null)
{
_pollTimer = Dispatcher.CreateTimer();
_pollTimer.Interval = TimeSpan.FromMilliseconds(100);
_pollTimer.Tick += async (_, _) => await DrainAsync();
_pollTimer.Start();
}
// Show local data immediately (offline-first), then sync in the background.
await _viewModel.LoadDataCommand.ExecuteAsync(null);
_ = _viewModel.SyncCommand.ExecuteAsync(null);
}
// Repaint the WebView editor when the app theme changes, and resize the
// sidebar column when it's collapsed/expanded (ColumnDefinition widths
// aren't bindable from XAML, so the ViewModel flag is applied here).
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(NotesViewModel.SelectedTheme) && _editorReady)
{
PushTheme();
}
else if (e.PropertyName == nameof(NotesViewModel.IsSidebarOpen))
{
ApplySidebarState();
}
}
private void ApplySidebarState()
{
SidebarColumn.Width = _viewModel.IsSidebarOpen ? new GridLength(210) : new GridLength(0);
SidebarPane.IsVisible = _viewModel.IsSidebarOpen;
}
// The ViewModel asks us to (re)load the editor's content.
private void OnEditorContentRequested(string html, bool keepHistory)
{
if (_editorReady)
{
LoadContent(html, keepHistory);
}
else
{
_pendingHtml = html; // pre-ready loads always start a fresh history
}
}
// ---- Outbound: C# -> JS via window.__inbox(base64) ----
private void SendToEditor(object payload)
{
var json = JsonSerializer.Serialize(payload);
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
MainThread.BeginInvokeOnMainThread(async () =>
{
try { await EditorWebView.EvaluateJavaScriptAsync($"window.__inbox('{b64}')"); }
catch { /* editor not ready yet */ }
});
}
// Each content load gets a new epoch. 'changed' messages echo the epoch they
// were typed under, so a message that describes a PREVIOUS note (drained but
// not yet dispatched when the user switched) can be recognized and dropped
// instead of overwriting the newly opened note.
private int _contentEpoch;
private void LoadContent(string html, bool keepHistory = false) =>
SendToEditor(new { action = "load", html, epoch = ++_contentEpoch, keepHistory });
private void PushTheme()
{
var t = ThemeManager.CurrentTheme;
SendToEditor(new
{
action = "theme",
theme = new
{
bg = t.Bg,
text = t.TextPrimary,
muted = t.TextMuted,
accent = t.Accent,
divider = t.Divider,
dark = t.IsDark
}
});
}
// ---- Inbound: JS -> C# via polling window.__drain() ----
private async Task DrainAsync()
{
if (_draining)
{
return;
}
_draining = true;
try
{
var raw = await EditorWebView.EvaluateJavaScriptAsync("window.__drain ? window.__drain() : ''");
var b64 = Sanitize(raw);
if (b64.Length == 0)
{
return;
}
string json;
try { json = Encoding.UTF8.GetString(Convert.FromBase64String(b64)); }
catch { return; }
using var doc = JsonDocument.Parse(json);
foreach (var el in doc.RootElement.EnumerateArray())
{
await DispatchAsync(el);
}
}
catch
{
// Ignore transient evaluation errors.
}
finally
{
_draining = false;
}
}
private async Task DispatchAsync(JsonElement el)
{
var type = el.GetProperty("type").GetString();
switch (type)
{
case "ready":
_editorReady = true;
PushTheme();
LoadContent(_pendingHtml);
break;
case "changed":
// Drop stale messages from a previously loaded note (see _contentEpoch).
var epoch = el.TryGetProperty("epoch", out var e) ? e.GetInt32() : 0;
if (epoch == _contentEpoch)
{
_viewModel.OnEditorContentChanged(el.GetProperty("html").GetString() ?? string.Empty);
}
break;
case "complete":
var id = el.GetProperty("id").GetInt32();
var context = el.GetProperty("context").GetString() ?? string.Empty;
await HandleCompletionAsync(id, context);
break;
// Block menu's "Ask AI": rewrite one block of the note per the user's
// instruction. Runs through the same API endpoint as the AI panel.
case "blockAi":
var blockId = el.GetProperty("id").GetInt32();
var blockEpoch = el.TryGetProperty("epoch", out var be) ? be.GetInt32() : 0;
var blockText = el.GetProperty("text").GetString() ?? string.Empty;
var instruction = el.GetProperty("instruction").GetString() ?? string.Empty;
_ = HandleBlockAiAsync(blockId, blockEpoch, blockText, instruction);
break;
}
}
// Fire-and-forget so a slow model doesn't stall the message-drain loop; the
// editor shows the pending state on the block until the reply lands. The
// epoch rides along so the editor can drop results for a note that's no
// longer open.
private async Task HandleBlockAiAsync(int id, int epoch, string text, string instruction)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(instruction))
{
SendToEditor(new { action = "blockAiResult", id, epoch, error = true });
return;
}
try
{
var result = await _aiService.RewriteAsync(text, instruction);
SendToEditor(new { action = "blockAiResult", id, epoch, html = MarkdownConverter.ToHtml(result) });
}
catch
{
// Model/API failure: clear the block's pending state; the AI panel's
// status line is not involved here, so there's nothing else to show.
SendToEditor(new { action = "blockAiResult", id, epoch, error = true });
}
}
// EvaluateJavaScriptAsync can return the string wrapped in quotes; strip them.
// base64 never contains quotes, so this is safe.
private static string Sanitize(string? raw)
{
if (string.IsNullOrEmpty(raw))
{
return string.Empty;
}
raw = raw.Trim();
if (raw.Length >= 2 && raw.StartsWith('"') && raw.EndsWith('"'))
{
raw = raw[1..^1];
}
return raw is "null" ? string.Empty : raw;
}
// Pulls the editor's current HTML directly (bypassing the debounced 'changed'
// path) so a save can never miss the very latest keystrokes.
private async Task<string?> FetchEditorContentAsync()
{
if (!_editorReady)
{
return null;
}
try
{
var raw = await EditorWebView.EvaluateJavaScriptAsync("window.__flush ? window.__flush() : ''");
var b64 = Sanitize(raw);
if (b64.Length == 0)
{
return null;
}
var json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("html").GetString();
}
catch
{
return null; // fall back to the last debounced content
}
}
// Fetch a short AI continuation and hand it back to the editor as ghost text.
private async Task HandleCompletionAsync(int id, string context)
{
try
{
var suggestion = await _aiService.CompleteAsync(context);
if (string.IsNullOrEmpty(suggestion))
{
return;
}
SendToEditor(new { action = "complete", id, text = suggestion });
}
catch
{
// Network/model hiccup: just skip this suggestion.
}
}
}