diff --git a/docs/capture-tool.md b/docs/capture-tool.md
index 5c507c3..33d6717 100644
--- a/docs/capture-tool.md
+++ b/docs/capture-tool.md
@@ -50,11 +50,15 @@ automatically. Known protected fields are excluded. Destination accessibility
checks inspect identity and state, not input text. The receiving app controls
whether it accepts images and where attachments appear.
-On Windows, attaching an image can briefly move focus to the containing page.
-Capture waits up to 1.5 seconds for the original input to regain focus, without
-moving the caret or sending the image again. A different input, window or replaced
-clipboard still stops the sequence. Presses while pasting do not queue another
-batch; briefly held shortcut keys pause the current sequence until released.
+On Windows, attaching an image can briefly move focus to the page or an upload
+control. Capture waits up to 1.5 seconds for the original editor to regain focus,
+using fresh accessibility state after each paste. A rich editor rebuilt in place
+can continue only when it uniquely replaces the original editor in the same
+container. Capture never moves the caret or sends an accepted image again.
+A different input, container, window or replaced clipboard still stops the
+sequence. Typing, navigating, clicking or scrolling during paste also stops it.
+Presses while pasting do not queue another batch; briefly held shortcut
+keys pause the current sequence until released.
## Menu and clipboard
diff --git a/src/Focalet.CaptureTool/CaptureInputIdentity.cs b/src/Focalet.CaptureTool/CaptureInputIdentity.cs
new file mode 100644
index 0000000..bded2dc
--- /dev/null
+++ b/src/Focalet.CaptureTool/CaptureInputIdentity.cs
@@ -0,0 +1,89 @@
+using FlaUI.Core.AutomationElements;
+using FlaUI.Core.Definitions;
+using FlaUI.UIA3;
+
+namespace Focalet.CaptureTool;
+
+/// Per-paste editor identity, without retaining a stale UIA provider or reading its text.
+internal sealed class CaptureInputIdentity
+{
+ private int[] runtimeId;
+ private readonly Replacement? replacement;
+
+ private CaptureInputIdentity(int[] runtimeId, Replacement? replacement)
+ => (this.runtimeId, this.replacement) = (runtimeId, replacement);
+
+ internal static AutomationElement Editor(UIA3Automation automation, AutomationElement focused)
+ {
+ var walker = automation.TreeWalkerFactory.GetRawViewWalker();
+ var depth = 0;
+ for (var node = focused; node is not null; node = walker.GetParent(node))
+ {
+ if (node.Properties.ControlType.ValueOrDefault == ControlType.Edit) return node;
+ if (node.Properties.ControlType.ValueOrDefault is ControlType.Document or ControlType.Window) break;
+ // Do not walk outside the focused control's bounded local ancestry.
+ if (++depth >= 8) break;
+ }
+ return focused;
+ }
+
+ internal static CaptureInputIdentity Remember(UIA3Automation automation, AutomationElement editor)
+ {
+ var id = Id(editor);
+ Replacement? replacement = null;
+ var shape = Shape.Read(editor);
+ if (shape.Type == ControlType.Edit)
+ {
+ var parent = automation.TreeWalkerFactory.GetRawViewWalker().GetParent(editor);
+ if (parent is not null && parent.Properties.ControlType.ValueOrDefault is not (ControlType.Document or ControlType.Window))
+ {
+ var children = Children(automation, parent);
+ if (children is not null && children.Count(child => Shape.Read(child) == shape) == 1)
+ replacement = new(Id(parent), shape, children.Select(Id).ToArray());
+ }
+ }
+ return new(id, replacement);
+ }
+
+ internal bool Matches(UIA3Automation automation, AutomationElement editor)
+ {
+ var id = Id(editor);
+ if (Same(runtimeId, id)) return true;
+ if (replacement is not { } previous || previous.Shape != Shape.Read(editor) ||
+ previous.Siblings.Any(sibling => Same(sibling, id))) return false;
+ var parent = automation.TreeWalkerFactory.GetRawViewWalker().GetParent(editor);
+ if (parent is null || !Same(previous.ParentId, Id(parent))) return false;
+ var children = Children(automation, parent);
+ // A new provider may represent a remounted editor. Accept only the unique
+ // replacement of our removed editor in its original, still-live container.
+ // A pre-existing sibling, a second matching editor or a new container is
+ // never treated as the selected input, even if its labels look identical.
+ if (children is null || children.Any(child => Same(runtimeId, Id(child))) ||
+ children.Count(child => previous.Shape == Shape.Read(child)) != 1 ||
+ !children.Any(child => Same(id, Id(child)))) return false;
+ runtimeId = id;
+ return true;
+ }
+
+ private static List? Children(UIA3Automation automation, AutomationElement parent)
+ {
+ var result = new List();
+ var walker = automation.TreeWalkerFactory.GetRawViewWalker();
+ for (var child = walker.GetFirstChild(parent); child is not null; child = walker.GetNextSibling(child))
+ {
+ if (result.Count == 64) return null;
+ result.Add(child);
+ }
+ return result;
+ }
+
+ private static int[] Id(AutomationElement element) => element.Properties.RuntimeId.Value;
+ private static bool Same(int[] first, int[] second) => first.Length > 0 && first.SequenceEqual(second);
+ private sealed record Replacement(int[] ParentId, Shape Shape, int[][] Siblings);
+ private sealed record Shape(ControlType Type, string AutomationId, string ClassName, string Framework)
+ {
+ internal static Shape Read(AutomationElement element) => new(element.Properties.ControlType.ValueOrDefault,
+ element.Properties.AutomationId.ValueOrDefault ?? "", element.Properties.ClassName.ValueOrDefault ?? "",
+ element.Properties.FrameworkId.ValueOrDefault ?? "");
+ }
+}
diff --git a/src/Focalet.CaptureTool/CapturePasteActivity.cs b/src/Focalet.CaptureTool/CapturePasteActivity.cs
new file mode 100644
index 0000000..8dcd118
--- /dev/null
+++ b/src/Focalet.CaptureTool/CapturePasteActivity.cs
@@ -0,0 +1,64 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+
+namespace Focalet.CaptureTool;
+
+/// Stop an active paste when the user types or navigates, even if an editor is rebuilt in place.
+internal sealed class CapturePasteActivity : IDisposable
+{
+ internal const nuint PasteTag = 0x46434C54;
+ private readonly Hook keyboardCallback;
+ private readonly Hook mouseCallback;
+ private nint keyboard;
+ private nint mouse;
+ internal bool Changed { get; private set; }
+
+ internal CapturePasteActivity()
+ {
+ keyboardCallback = Keyboard;
+ mouseCallback = Mouse;
+ var module = GetModuleHandle(null);
+ keyboard = SetWindowsHookEx(13, keyboardCallback, module, 0);
+ mouse = SetWindowsHookEx(14, mouseCallback, module, 0);
+ if (keyboard == 0 || mouse == 0)
+ {
+ var error = Marshal.GetLastWin32Error();
+ Dispose();
+ throw new Win32Exception(error, "Could not watch destination input changes.");
+ }
+ }
+
+ private nint Keyboard(int code, nint message, nint data)
+ {
+ if (code >= 0 && message is 0x0100 or 0x0104)
+ {
+ var key = Marshal.PtrToStructure(data);
+ // Ignore only our own injected paste, modifier holds and busy Alt+A.
+ // Other typing/navigation cancels, including input from another tool.
+ if (key.Extra != PasteTag && key.Key is not (0x10 or 0x11 or 0x12 or >= 0xA0 and <= 0xA5) &&
+ !(key.Key == 0x41 && (GetAsyncKeyState(0x12) & 0x8000) != 0)) Changed = true;
+ }
+ return CallNextHookEx(0, code, message, data);
+ }
+
+ private nint Mouse(int code, nint message, nint data)
+ {
+ // Moving the pointer is harmless; clicks and scrolling express new intent.
+ if (code >= 0 && message is 0x0201 or 0x0204 or 0x0207 or 0x020A or 0x020B or 0x020E) Changed = true;
+ return CallNextHookEx(0, code, message, data);
+ }
+
+ public void Dispose()
+ {
+ if (keyboard != 0) { UnhookWindowsHookEx(keyboard); keyboard = 0; }
+ if (mouse != 0) { UnhookWindowsHookEx(mouse); mouse = 0; }
+ }
+
+ private delegate nint Hook(int code, nint message, nint data);
+ [StructLayout(LayoutKind.Sequential)] private struct KeyboardEvent { public uint Key, Scan, Flags, Time; public nuint Extra; }
+ [DllImport("user32.dll", SetLastError = true)] private static extern nint SetWindowsHookEx(int kind, Hook callback, nint module, uint thread);
+ [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(nint hook);
+ [DllImport("user32.dll")] private static extern nint CallNextHookEx(nint hook, int code, nint message, nint data);
+ [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int key);
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern nint GetModuleHandle(string? name);
+}
diff --git a/src/Focalet.CaptureTool/CapturePasteSequence.cs b/src/Focalet.CaptureTool/CapturePasteSequence.cs
index c7f1e38..48f8ab8 100644
--- a/src/Focalet.CaptureTool/CapturePasteSequence.cs
+++ b/src/Focalet.CaptureTool/CapturePasteSequence.cs
@@ -15,18 +15,19 @@ internal static class CapturePasteSequence
public static async Task PasteAsync(CaptureClipboardBatch batch, CapturePasteTarget target, bool textOnly, bool slowerImages = false)
{
using var clipboard = new PasteClipboard();
+ using var activity = new CapturePasteActivity();
var sent = 0;
var unreadImages = 0;
for (var index = 0; index < batch.Items.Count; index++)
{
foreach (var image in textOnly ? new[] { false } : new[] { true, false })
{
- if (await WaitForInputAsync(target, clipboard, sent > 0) is { } notReady)
+ if (await WaitForInputAsync(target, clipboard, activity, sent > 0) is { } notReady)
return new(sent, unreadImages, notReady);
if (image) clipboard.SetImage(batch.Items[index]);
else clipboard.SetText(batch.TextParts[index]);
clipboard.Arm();
- if (await WaitForInputAsync(target, clipboard, true) is { } interrupted)
+ if (await WaitForInputAsync(target, clipboard, activity, true) is { } interrupted)
return new(sent, unreadImages, interrupted);
if (!target.Paste(CapturePasteTarget.GetClipboardSequenceNumber(), clipboard.Handle))
return new(sent, unreadImages, "Paste could not be dispatched. It will not be retried.");
@@ -44,6 +45,7 @@ public static async Task PasteAsync(CaptureClipboardBatch ba
while (wait.ElapsedMilliseconds < (image ? 5000 : 3000))
{
await Task.Delay(50);
+ if (activity.Changed) return new(sent, unreadImages, "Keyboard or mouse input interrupted paste.");
if (!clipboard.OwnsClipboard)
return new(sent, unreadImages, "Another application replaced the clipboard.");
if (!target.IsCurrent()) return new(sent, unreadImages, "The destination window changed during paste.");
@@ -69,14 +71,16 @@ public static async Task PasteAsync(CaptureClipboardBatch ba
return new(sent, unreadImages);
}
- private static async Task WaitForInputAsync(CapturePasteTarget target, PasteClipboard clipboard, bool checkClipboard)
+ private static async Task WaitForInputAsync(CapturePasteTarget target, PasteClipboard clipboard, CapturePasteActivity activity, bool checkClipboard)
{
var wait = Stopwatch.StartNew();
while (true)
{
+ if (activity.Changed) return "Keyboard or mouse input interrupted paste.";
if (!target.IsCurrent()) return "The destination window changed during paste.";
if (checkClipboard && !clipboard.OwnsClipboard) return "Another application replaced the clipboard.";
var state = await target.InputStateAsync();
+ if (activity.Changed) return "Keyboard or mouse input interrupted paste.";
if (!target.IsCurrent()) return "The destination window changed during paste.";
if (checkClipboard && !clipboard.OwnsClipboard) return "Another application replaced the clipboard.";
if (state == CaptureInputState.Changed) return "The focused input changed during paste.";
diff --git a/src/Focalet.CaptureTool/CapturePasteTarget.cs b/src/Focalet.CaptureTool/CapturePasteTarget.cs
index 33325fd..154270f 100644
--- a/src/Focalet.CaptureTool/CapturePasteTarget.cs
+++ b/src/Focalet.CaptureTool/CapturePasteTarget.cs
@@ -16,7 +16,7 @@ internal sealed record CaptureInputObservation(CapturePasteTarget Target, Captur
internal sealed record CapturePasteTarget(nint Window, nint Focus, uint ProcessId, long ProcessStarted)
: WindowFocus(Window, Focus, ProcessId, ProcessStarted)
{
- private AutomationElement? InputElement { get; init; }
+ private CaptureInputIdentity? InputIdentity { get; init; }
private UIA3Automation? InputAutomation { get; init; }
public string Description => NativeCaptureWindow.Title(Window);
public new static CapturePasteTarget? Remember()
@@ -57,7 +57,11 @@ internal static CaptureInputObservation ObserveInput(UIA3Automation automation,
if (!target.IsCurrent() || !element.Properties.HasKeyboardFocus.ValueOrDefault) return Unknown();
// The user already chose the caret. Keep identity for interruption checks,
// without selecting UIA output ranges or moving focus.
- return new(target with { InputElement = element, InputAutomation = automation }, CaptureInputKind.Input);
+ var editor = CaptureInputIdentity.Editor(automation, element);
+ if (editor.Properties.IsPassword.ValueOrDefault || !editor.Properties.IsEnabled.ValueOrDefault)
+ return new(target, CaptureInputKind.Protected);
+ var identity = CaptureInputIdentity.Remember(automation, editor);
+ return new(target with { InputIdentity = identity, InputAutomation = automation }, CaptureInputKind.Input);
}
internal static bool IsTerminalControl(string windowClass, string? inputClass) =>
@@ -78,13 +82,11 @@ public async Task IsInputCurrentAsync()
internal async Task InputStateAsync()
{
if (!IsCurrent()) return CaptureInputState.Changed;
- if (InputElement is null) return CaptureInputState.Current;
+ if (InputIdentity is null) return CaptureInputState.Current;
return await Task.Run(() =>
{
try
{
- if (InputElement.Properties.HasKeyboardFocus.ValueOrDefault)
- return IsCurrent() ? CaptureInputState.Current : CaptureInputState.Changed;
var focused = InputAutomation?.FocusedElement();
if (!IsCurrent()) return CaptureInputState.Changed;
if (focused is null) return CaptureInputState.Settling;
@@ -92,18 +94,25 @@ internal async Task InputStateAsync()
if (process != ProcessId && (Focus == 0 || process != WindowProcess(Focus))) return CaptureInputState.Changed;
var window = focused.Properties.NativeWindowHandle.ValueOrDefault;
if (window != 0 && !ContainsWindow(window)) return CaptureInputState.Changed;
- if (InputAutomation?.Compare(InputElement, focused) == true) return CaptureInputState.Settling;
- if (focused.Properties.IsPassword.ValueOrDefault ||
- focused.Patterns.Value.PatternOrDefault?.IsReadOnly.ValueOrDefault == false)
+ if (!focused.Properties.HasKeyboardFocus.ValueOrDefault) return CaptureInputState.Settling;
+ var editor = CaptureInputIdentity.Editor(InputAutomation!, focused);
+ if (editor.Properties.IsPassword.ValueOrDefault || !editor.Properties.IsEnabled.ValueOrDefault)
+ return CaptureInputState.Changed;
+ if (editor.Properties.ControlType.ValueOrDefault == ControlType.Edit &&
+ editor.Patterns.Value.PatternOrDefault?.IsReadOnly.ValueOrDefault == true)
+ return CaptureInputState.Settling;
+ if (InputIdentity.Matches(InputAutomation!, editor))
+ return IsCurrent() ? CaptureInputState.Current : CaptureInputState.Changed;
+ if (editor.Properties.ControlType.ValueOrDefault == ControlType.Edit ||
+ editor.Patterns.Value.PatternOrDefault?.IsReadOnly.ValueOrDefault == false)
return CaptureInputState.Changed;
- // An attachment can briefly move accessibility focus to the
- // containing page while the SAME editor is being updated.
- // Never bind to another editor or move the user's caret.
- var type = focused.Properties.ControlType.ValueOrDefault;
- return type == ControlType.Document || type == ControlType.Pane || type == ControlType.Window
- ? CaptureInputState.Settling : CaptureInputState.Changed;
+ // Upload controls can temporarily take focus. Wait for the
+ // original editor; never paste into the temporary control.
+ return CaptureInputState.Settling;
}
- catch (Exception error) when (error is not OutOfMemoryException) { return CaptureInputState.Changed; }
+ // UIA providers can be temporarily unavailable while a rich editor
+ // updates. An unreadable provider is not evidence of another input.
+ catch (Exception error) when (error is not OutOfMemoryException) { return CaptureInputState.Settling; }
});
}
@@ -131,7 +140,7 @@ public bool Paste(uint clipboardSequence, nint clipboardOwner = 0)
private static Input Key(ushort value, bool up = false) => new()
{
- Type = 1, Data = new InputData { Keyboard = new KeyboardInput { Key = value, Flags = up ? 2u : 0u } },
+ Type = 1, Data = new InputData { Keyboard = new KeyboardInput { Key = value, Flags = up ? 2u : 0u, Extra = CapturePasteActivity.PasteTag } },
};
[StructLayout(LayoutKind.Sequential)]
diff --git a/src/Focalet.CaptureTool/CapturePasteTool.cs b/src/Focalet.CaptureTool/CapturePasteTool.cs
index 1946eed..fda04c9 100644
--- a/src/Focalet.CaptureTool/CapturePasteTool.cs
+++ b/src/Focalet.CaptureTool/CapturePasteTool.cs
@@ -148,7 +148,6 @@ private async void Paste()
if (observation.Kind == CaptureInputKind.Protected)
{ Notify("Paste cancelled", "The selected input is protected or read-only."); return; }
target = observation.Target;
- if (!await target.IsInputCurrentAsync()) return;
// Retain the capture for another deliberate Alt+A at its current
// destination. Busy invocations are ignored, never queued or retried.
var result = await CapturePasteSequence.PasteAsync(batch, target, textOnly, slowerImages);
diff --git a/src/Focalet.Flutter/pubspec.yaml b/src/Focalet.Flutter/pubspec.yaml
index 3c7e0f7..814196c 100644
--- a/src/Focalet.Flutter/pubspec.yaml
+++ b/src/Focalet.Flutter/pubspec.yaml
@@ -2,7 +2,7 @@ name: focalet_flutter
description: Focalet's cross-platform floating UI.
repository: https://github.com/timctho/focalet
publish_to: 'none'
-version: 0.3.1+15
+version: 0.3.2+16
environment:
sdk: ^3.13.2
diff --git a/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs b/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs
index 18a89ce..3306fad 100644
--- a/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs
+++ b/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs
@@ -117,6 +117,16 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
await Task.Delay(700);
if (!context.HasBatch || previous.Rtf != interrupted || Clipboard.GetText() != "interrupted hotkey fixture")
throw new InvalidOperationException("Interrupted paste resumed automatically or discarded the batch.");
+ notices.Clear();
+ picturesBeforeInterruption = Count(previous.Rtf!, @"\pict");
+ SendHotkey(capture: false);
+ await ForegroundRoutingAcceptance.WaitFor(() => Count(previous.Rtf!, @"\pict") > picturesBeforeInterruption,
+ "Navigation fixture did not receive its first image.");
+ SendInput(2, [Key(0x27), Key(0x27, true)], Marshal.SizeOf()); // Move the caret in the SAME input.
+ await ForegroundRoutingAcceptance.WaitFor(() => notices.Contains("Paste stopped") && !context.Busy,
+ "User navigation within the same input did not stop the batch.");
+ if (Count(previous.Rtf!, @"\pict") != picturesBeforeInterruption + 1)
+ throw new InvalidOperationException("Paste continued after user navigation.");
if (enters != 0 || captured != 2) throw new InvalidOperationException("Repeated paste sent Enter or captured again.");
Console.WriteLine("PASS Shift+Alt+A captures without clipboard changes; deliberate Alt+A repeats silently at the current input; busy presses do not queue; cancellation and interruption retain the batch without automatic retries.");
}
diff --git a/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs b/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs
index ad13b66..876cf60 100644
--- a/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs
+++ b/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs
@@ -12,10 +12,11 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
var fixture = Path.GetFullPath("tests/clipboard-electron");
var electron = Path.Combine(fixture, "node_modules/electron/dist/electron.exe");
if (!File.Exists(electron)) throw new InvalidOperationException("Install the locked tests/clipboard-electron dependencies first.");
- foreach (var mode in new[] { "terminal", "native-chat", "native-chat-transient-focus", "native-chat-other-input" })
+ foreach (var mode in new[] { "terminal", "native-chat", "native-chat-transient-focus", "native-chat-other-input",
+ "native-chat-rich-remount", "native-chat-rich-upload-focus", "native-chat-rich-new-container", "native-chat-rich-retained-editor", "native-chat-rich-sibling" })
foreach (var slowerImages in new[] { false, true })
{
- if (slowerImages && mode is "native-chat-transient-focus" or "native-chat-other-input") continue;
+ if (slowerImages && mode is not ("terminal" or "native-chat")) continue;
var pace = slowerImages ? "slower" : "fast";
var temporary = Path.Combine(Path.GetTempPath(), "focalet-electron-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temporary);
@@ -37,6 +38,7 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
?? throw new InvalidOperationException("Electron fixture input is unavailable.");
input.Focus();
await ForegroundRoutingAcceptance.WaitFor(() => input.Properties.HasKeyboardFocus.ValueOrDefault, "Electron input did not acquire focus.");
+ Console.WriteLine($"Electron {mode}: control={input.Properties.ControlType.ValueOrDefault}, class={input.Properties.ClassName.ValueOrDefault}, id={input.Properties.AutomationId.ValueOrDefault}");
target = CapturePasteTarget.RememberWindow() ?? throw new InvalidOperationException("Electron input was not current.");
var observation = await Task.Run(() => CapturePasteTarget.ObserveInput(automation, target));
if (observation.Kind != CaptureInputKind.Input) throw new InvalidOperationException("Electron editor was not recognized as the chosen input.");
@@ -44,7 +46,7 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
var elapsed = Stopwatch.StartNew();
var result = await CapturePasteSequence.PasteAsync(batch, target, false, slowerImages);
elapsed.Stop();
- if (mode == "native-chat-other-input")
+ if (mode is "native-chat-other-input" or "native-chat-rich-new-container" or "native-chat-rich-retained-editor" or "native-chat-rich-sibling")
{
if (result.StoppedBecause is null || result.StepsSent != 1)
throw new InvalidOperationException("Paste continued into a different editor: " + result);
@@ -52,7 +54,7 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
using var stopped = JsonDocument.Parse(File.ReadAllText(Path.Combine(temporary, "result.json")));
if (stopped.RootElement.GetProperty("events").GetArrayLength() != 1)
throw new InvalidOperationException("Changing editors dispatched more than the first image.");
- Console.WriteLine("PASS Electron paste stops after the accepted image when a different editor gains focus.");
+ Console.WriteLine($"PASS Electron {mode} stops after the accepted image when a different editor gains focus.");
continue;
}
if (result.StoppedBecause is not null) throw new InvalidOperationException("Electron sequence stopped: " + result);
@@ -60,6 +62,8 @@ public static async Task RunAsync(CaptureClipboardBatch batch)
var json = File.ReadAllText(Path.Combine(temporary, "result.json"));
using var document = JsonDocument.Parse(json);
var events = document.RootElement.GetProperty("events").EnumerateArray().ToArray();
+ if (mode == "native-chat-rich-remount" && events[^1].GetProperty("replacements").GetInt32() != 4)
+ throw new InvalidOperationException("Rich editor fixture did not remount after every paste.");
Console.WriteLine($"Electron {mode} {pace}: " + JsonSerializer.Serialize(new {
elapsedMs = elapsed.ElapsedMilliseconds, events = events.Select(e => e.GetProperty("kind").GetString()), diagnostics = document.RootElement.GetProperty("diagnostics") }));
if (!events.Select(e => e.GetProperty("kind").GetString()).SequenceEqual(new[] { "image", "text", "image", "text" }))
diff --git a/tests/clipboard-electron/receiver.html b/tests/clipboard-electron/receiver.html
index 8e7a6e2..527e066 100644
--- a/tests/clipboard-electron/receiver.html
+++ b/tests/clipboard-electron/receiver.html
@@ -1,15 +1,50 @@
Focalet Electron clipboard fixture
-
+
Native Electron image and context
-
+
+