- 클립보드 히스토리, 클립보드 변환, 순차 붙여넣기 실행 경로에 공통 포커스 복원 helper를 추가했습니다. - 이전 활성 창 복원, 최소 대기, Ctrl+V 주입 순서를 하나로 맞춰 포커스 누락으로 내용이 원래 창에 들어가지 않던 문제를 완화했습니다. - 관련 변경 이력을 README와 DEVELOPMENT 문서에 반영했습니다. 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ - 경고 0 / 오류 0
This commit is contained in:
@@ -1040,4 +1040,6 @@ MIT License
|
||||
- 업데이트: 2026-04-05 22:53 (KST)
|
||||
- 하단 컨텍스트 토큰 라벨이 hover 후 남아 있던 문제를 수정하고, 토큰 심볼/팝업의 흐린 배경·그림자 느낌을 줄여 더 깔끔한 테두리 중심 스타일로 정리했다.
|
||||
- 업데이트: 2026-04-05 22:57 (KST)
|
||||
- 채팅/코워크 프리셋 카드 hover 시 설명 라벨을 `Collapsed/Visible`로 토글하던 방식을 없애고, 같은 자리에서 `Opacity`만 바꾸도록 조정해 카드가 깜빡이듯 다시 그려지던 현상을 줄였다.
|
||||
- 채팅/코워크 프리셋 카드 hover 시 설명 라벨을 `Collapsed/Visible`로 토글하던 방식을 없애고, 같은 자리에서 `Opacity`만 바꾸도록 조정해 카드가 깜빡이듯 다시 그려지던 현상을 줄였다.
|
||||
- 업데이트: 2026-04-05 20:17 (KST)
|
||||
- 런처의 클립보드 히스토리/클립보드 변환/순차 붙여넣기 실행 경로를 공통 포커스 복원 helper 기반으로 정리했다. 이전 활성 창 복원, 최소 대기, Ctrl+V 주입 순서를 [ForegroundPasteHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ForegroundPasteHelper.cs) 로 통일해 포커스가 원래 창으로 돌아가지 않아 붙여넣기가 누락되던 문제를 줄였다.
|
||||
|
||||
@@ -4786,3 +4786,5 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 하단 컨텍스트 토큰 심볼과 hover 팝업을 다듬었다. 토큰 카드가 숨겨질 때 팝업도 함께 강제 종료되도록 보강했고, hover 종료 시 실제로 카드/팝업 둘 다 벗어난 경우에만 닫히게 조건을 정리했다. 또 심볼과 팝업은 흐린 배경/강한 그림자 대신 얇은 테두리와 약한 그림자 중심으로 수정했다.
|
||||
- 업데이트: 2026-04-05 22:57 (KST)
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 `BuildTopicButtons()`에서 프리셋 카드 hover 설명 라벨을 `Visibility` 토글 대신 `Opacity` 전환 방식으로 바꿨다. 카드 hover 중 설명 라벨이 나타날 때 레이아웃이 다시 잡히며 깜빡이던 현상을 줄이기 위한 보정이다.
|
||||
- 업데이트: 2026-04-05 20:17 (KST)
|
||||
- 런처 클립보드 붙여넣기 포커스 경로를 공통 helper 기반으로 재정리했다. [ForegroundPasteHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ForegroundPasteHelper.cs) 를 추가해 이전 활성 창 복원, 최소 대기, `Ctrl+V` 주입 순서를 하나로 통일했고, [ClipboardHistoryHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/ClipboardHistoryHandler.cs), [ClipboardHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/ClipboardHandler.cs), [PasteHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/PasteHandler.cs) 가 모두 같은 포커스 복원 로직을 사용하도록 맞췄다.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -26,7 +25,6 @@ public class ClipboardHandler : IActionHandler
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 빌트인 변환 목록
|
||||
var builtins = GetBuiltinTransformers()
|
||||
.Where(t => string.IsNullOrEmpty(query) ||
|
||||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase));
|
||||
@@ -34,14 +32,12 @@ public class ClipboardHandler : IActionHandler
|
||||
foreach (var t in builtins)
|
||||
items.Add(new LauncherItem(t.Key, t.Description ?? "", null, t, Symbol: Symbols.Clipboard));
|
||||
|
||||
// 사용자 정의 변환
|
||||
var custom = _settings.Settings.ClipboardTransformers
|
||||
.Where(t => string.IsNullOrEmpty(query) ||
|
||||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(t => new LauncherItem(t.Key, t.Description ?? t.Type, null, t, Symbol: Symbols.Clipboard));
|
||||
|
||||
items.AddRange(custom);
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
@@ -49,7 +45,6 @@ public class ClipboardHandler : IActionHandler
|
||||
{
|
||||
if (item.Data is not ClipboardTransformer transformer) return;
|
||||
|
||||
// 클립보드에서 텍스트 읽기 (STA 스레드 필요)
|
||||
string? input = null;
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
@@ -61,17 +56,12 @@ public class ClipboardHandler : IActionHandler
|
||||
string? result = await TransformAsync(transformer, input, ct);
|
||||
if (result == null) return;
|
||||
|
||||
// 결과를 클립보드에 쓰고 이전 활성 창으로 포커스 복원 후 붙여넣기
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
|
||||
|
||||
// 런처 호출 전 활성 창으로 포커스 복원
|
||||
var prevHwnd = WindowTracker.PreviousWindow;
|
||||
if (prevHwnd != IntPtr.Zero)
|
||||
SetForegroundWindow(prevHwnd);
|
||||
|
||||
await Task.Delay(120, ct);
|
||||
System.Windows.Forms.SendKeys.SendWait("^v");
|
||||
if (prevHwnd == IntPtr.Zero) return;
|
||||
|
||||
await ForegroundPasteHelper.PasteClipboardAsync(prevHwnd, ct, initialDelayMs: 220);
|
||||
LogService.Info($"클립보드 변환: '{transformer.Key}' 적용");
|
||||
}
|
||||
|
||||
@@ -81,7 +71,6 @@ public class ClipboardHandler : IActionHandler
|
||||
{
|
||||
return t.Type switch
|
||||
{
|
||||
// ReDoS 방지: 사용자 정의 패턴에 타임아웃 적용
|
||||
"regex" when t.Pattern != null && t.Replace != null =>
|
||||
Regex.Replace(input, t.Pattern, t.Replace, RegexOptions.None,
|
||||
TimeSpan.FromMilliseconds(t.Timeout > 0 ? t.Timeout : 5000)),
|
||||
@@ -120,8 +109,6 @@ public class ClipboardHandler : IActionHandler
|
||||
return await proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
}
|
||||
|
||||
// ─── 빌트인 변환 ─────────────────────────────────────────────────────────
|
||||
|
||||
internal static string? ExecuteBuiltin(string key, string input)
|
||||
{
|
||||
return key switch
|
||||
@@ -149,7 +136,10 @@ public class ClipboardHandler : IActionHandler
|
||||
var doc = JsonDocument.Parse(input);
|
||||
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch { return input; }
|
||||
catch
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryParseTimestamp(string input)
|
||||
@@ -159,6 +149,7 @@ public class ClipboardHandler : IActionHandler
|
||||
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).LocalDateTime;
|
||||
return dt.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -166,15 +157,13 @@ public class ClipboardHandler : IActionHandler
|
||||
{
|
||||
if (DateTime.TryParse(input.Trim(), out var dt))
|
||||
return new DateTimeOffset(dt).ToUnixTimeSeconds().ToString();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string StripMarkdown(string input) =>
|
||||
Regex.Replace(input, @"(\*\*|__)(.*?)\1|(\*|_)(.*?)\3|`(.+?)`|#{1,6}\s*", "$2$4$5");
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
private static IEnumerable<ClipboardTransformer> GetBuiltinTransformers() =>
|
||||
[
|
||||
new() { Key = "$json", Type = "builtin", Description = "JSON 포맷팅 (들여쓰기 적용)" },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
@@ -28,7 +27,6 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
_historyService = historyService;
|
||||
}
|
||||
|
||||
// 카테고리 필터 프리픽스: #url, #코드, #경로
|
||||
private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "url", "URL" }, { "코드", "코드" }, { "code", "코드" },
|
||||
@@ -52,7 +50,6 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
|
||||
// 카테고리 필터 감지 (예: #url, #핀)
|
||||
string? catFilter = null;
|
||||
foreach (var (prefix, cat) in CategoryFilters)
|
||||
{
|
||||
@@ -66,17 +63,14 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
|
||||
var filtered = history.AsEnumerable();
|
||||
|
||||
// 카테고리 필터 적용
|
||||
if (catFilter == "핀")
|
||||
filtered = filtered.Where(e => e.IsPinned);
|
||||
else if (catFilter != null)
|
||||
filtered = filtered.Where(e => e.Category == catFilter);
|
||||
|
||||
// 텍스트 검색
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q));
|
||||
|
||||
// 핀 항목을 상단에 배치
|
||||
var sorted = filtered
|
||||
.OrderByDescending(e => e.IsPinned)
|
||||
.ThenByDescending(e => e.CopiedAt);
|
||||
@@ -113,14 +107,13 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
try
|
||||
{
|
||||
_historyService.SuppressNextCapture();
|
||||
_historyService.PromoteEntry(entry); // 사용 시각 갱신 + 목록 맨 위로
|
||||
_historyService.PromoteEntry(entry);
|
||||
|
||||
if (!entry.IsText && entry.Image != null)
|
||||
{
|
||||
// 원본 이미지가 있으면 원본 해상도로 클립보드 복사
|
||||
var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath);
|
||||
Clipboard.SetImage(originalImg ?? entry.Image);
|
||||
return; // 이미지는 붙여넣기 시뮬레이션 없이 클립보드만 설정
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Text)) return;
|
||||
@@ -129,25 +122,7 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
var prevWindow = WindowTracker.PreviousWindow;
|
||||
if (prevWindow == IntPtr.Zero) return;
|
||||
|
||||
// ── 이전 창 포커스 복원 후 Ctrl+V ────────────────────────────────
|
||||
// 런처 창이 완전히 숨겨지고 이전 창이 포커스를 회복할 시간 확보
|
||||
await Task.Delay(300, ct);
|
||||
|
||||
// AttachThreadInput으로 포그라운드 전환 권한 획득 후 SetForegroundWindow 호출
|
||||
// (Alt 트릭 대비: Alt 키 주입 없이 안정적으로 전환, 대상 앱 메뉴 트리거 방지)
|
||||
var targetThread = GetWindowThreadProcessId(prevWindow, out _);
|
||||
var currentThread = GetCurrentThreadId();
|
||||
AttachThreadInput(currentThread, targetThread, true);
|
||||
SetForegroundWindow(prevWindow);
|
||||
AttachThreadInput(currentThread, targetThread, false);
|
||||
|
||||
// 포커스 전환이 완전히 반영될 때까지 대기
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
// SendInput으로 Ctrl+V 주입
|
||||
// Win32 INPUT 구조체: type(4) + 패딩(4) + union(32) = 40 bytes on x64
|
||||
// KEYBDINPUT.dwExtraInfo = ULONG_PTR → union 8바이트 정렬 필요 → offset 8에서 시작
|
||||
SendCtrlV();
|
||||
await ForegroundPasteHelper.PasteClipboardAsync(prevWindow, ct, initialDelayMs: 260);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
@@ -155,62 +130,4 @@ public class ClipboardHistoryHandler : IActionHandler
|
||||
LogService.Warn($"클립보드 히스토리 붙여넣기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SendCtrlV()
|
||||
{
|
||||
const uint INPUT_KEYBOARD = 1;
|
||||
const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
const ushort VK_CONTROL = 0x11;
|
||||
const ushort VK_V = 0x56;
|
||||
|
||||
var inputs = new INPUT[4];
|
||||
|
||||
// Ctrl 누름
|
||||
inputs[0].Type = INPUT_KEYBOARD;
|
||||
inputs[0].ki.wVk = VK_CONTROL;
|
||||
|
||||
// V 누름
|
||||
inputs[1].Type = INPUT_KEYBOARD;
|
||||
inputs[1].ki.wVk = VK_V;
|
||||
|
||||
// V 뗌
|
||||
inputs[2].Type = INPUT_KEYBOARD;
|
||||
inputs[2].ki.wVk = VK_V;
|
||||
inputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
|
||||
// Ctrl 뗌
|
||||
inputs[3].Type = INPUT_KEYBOARD;
|
||||
inputs[3].ki.wVk = VK_CONTROL;
|
||||
inputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
|
||||
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
}
|
||||
|
||||
// ─── P/Invoke ──────────────────────────────────────────────────────────
|
||||
|
||||
// Win32 INPUT 구조체 — x64에서 40바이트
|
||||
// type(4) + 패딩(4) + union은 offset 8에서 시작 (MOUSEINPUT 32바이트가 최대)
|
||||
[StructLayout(LayoutKind.Explicit, Size = 40)]
|
||||
private struct INPUT
|
||||
{
|
||||
[FieldOffset(0)] public uint Type;
|
||||
[FieldOffset(8)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
// KEYBDINPUT: 2+2+4+4 = 12, dwExtraInfo(IntPtr)는 8바이트 정렬 → 패딩 포함 24바이트
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] pInputs, int cbSize);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
@@ -7,13 +6,8 @@ using AxCopilot.Themes;
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-6: 클립보드 순차 붙여넣기 핸들러. "paste" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: paste → 번호 매긴 클립보드 히스토리 목록
|
||||
/// paste 3 1 5 → 3번→1번→5번 항목을 순서대로 붙여넣기
|
||||
/// paste all → 전체 히스토리를 순서대로 붙여넣기
|
||||
/// Enter → 이전 창에서 순서대로 Ctrl+V 실행.
|
||||
/// Raycast "Paste Sequentially" 대응.
|
||||
/// paste prefix 핸들러: 클립보드 히스토리를 순서대로 붙여넣습니다.
|
||||
/// 예: paste / paste 3 1 5 / paste all
|
||||
/// </summary>
|
||||
public class PasteHandler : IActionHandler
|
||||
{
|
||||
@@ -34,7 +28,7 @@ public class PasteHandler : IActionHandler
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var history = _history.History.Where(e => e.IsText && !string.IsNullOrEmpty(e.Text)).ToList();
|
||||
|
||||
@@ -47,7 +41,6 @@ public class PasteHandler : IActionHandler
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 번호 시퀀스 파싱 ──────────────────────────────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(q) && q != "all")
|
||||
{
|
||||
var nums = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -60,16 +53,15 @@ public class PasteHandler : IActionHandler
|
||||
|
||||
if (indices.Count > 0)
|
||||
{
|
||||
var preview = string.Join(" → ", indices.Select(i => $"#{i}"));
|
||||
var preview = string.Join(" · ", indices.Select(i => $"#{i}"));
|
||||
var texts = indices.Select(i => history[i - 1].Text ?? "").ToList();
|
||||
var totalLen = texts.Sum(t => t.Length);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"순차 붙여넣기: {preview}",
|
||||
$"{indices.Count}개 항목 · {totalLen}자 · Enter: 순서대로 붙여넣기",
|
||||
$"{indices.Count}개 항목 · 총 {totalLen}자 · Enter로 순서대로 붙여넣기",
|
||||
null, ("seq", texts), Symbol: Symbols.ClipPaste));
|
||||
|
||||
// 미리보기
|
||||
for (int i = 0; i < indices.Count; i++)
|
||||
{
|
||||
var entry = history[indices[i] - 1];
|
||||
@@ -83,21 +75,19 @@ public class PasteHandler : IActionHandler
|
||||
}
|
||||
}
|
||||
|
||||
// ── all 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var texts = history.Take(20).Select(e => e.Text ?? "").ToList();
|
||||
items.Add(new LauncherItem(
|
||||
$"전체 순차 붙여넣기 ({texts.Count}개)",
|
||||
$"Enter: 최근 {texts.Count}개 항목을 순서대로 붙여넣기",
|
||||
$"Enter로 최근 {texts.Count}개 항목을 순서대로 붙여넣기",
|
||||
null, ("seq", texts), Symbol: Symbols.ClipPaste));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 번호 매긴 목록 ─────────────────────────────────────────
|
||||
items.Add(new LauncherItem(
|
||||
"순차 붙여넣기 — 번호를 입력하세요",
|
||||
"예: paste 3 1 5 → 3번→1번→5번 순서로 붙여넣기 · paste all → 전체",
|
||||
"순차 붙여넣기 번호를 입력하세요",
|
||||
"예: paste 3 1 5 · paste all",
|
||||
null, null, Symbol: Symbols.ClipPaste));
|
||||
|
||||
for (int i = 0; i < Math.Min(history.Count, 15); i++)
|
||||
@@ -117,12 +107,10 @@ public class PasteHandler : IActionHandler
|
||||
{
|
||||
if (item.Data is ("single", string singleText))
|
||||
{
|
||||
// 단일 항목 붙여넣기
|
||||
await PasteTexts([singleText], ct);
|
||||
}
|
||||
else if (item.Data is ("seq", List<string> texts))
|
||||
{
|
||||
// 순차 붙여넣기
|
||||
await PasteTexts(texts, ct);
|
||||
}
|
||||
}
|
||||
@@ -138,16 +126,9 @@ public class PasteHandler : IActionHandler
|
||||
|
||||
_history.SuppressNextCapture();
|
||||
|
||||
// 이전 창 포커스 복원 대기
|
||||
await Task.Delay(300, ct);
|
||||
|
||||
var targetThread = GetWindowThreadProcessId(prevWindow, out _);
|
||||
var currentThread = GetCurrentThreadId();
|
||||
AttachThreadInput(currentThread, targetThread, true);
|
||||
SetForegroundWindow(prevWindow);
|
||||
AttachThreadInput(currentThread, targetThread, false);
|
||||
|
||||
await Task.Delay(100, ct);
|
||||
var restored = await ForegroundPasteHelper.RestoreWindowAsync(prevWindow, ct, initialDelayMs: 260);
|
||||
if (!restored)
|
||||
return;
|
||||
|
||||
foreach (var text in texts)
|
||||
{
|
||||
@@ -157,8 +138,8 @@ public class PasteHandler : IActionHandler
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
await Task.Delay(50, ct);
|
||||
|
||||
SendCtrlV();
|
||||
await Task.Delay(200, ct); // 항목 간 간격
|
||||
await ForegroundPasteHelper.PasteClipboardAsync(prevWindow, ct, initialDelayMs: 0);
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
NotificationService.Notify("paste", $"{texts.Count}개 항목 붙여넣기 완료");
|
||||
@@ -172,46 +153,4 @@ public class PasteHandler : IActionHandler
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
=> s.Length <= max ? s : s[..max] + "…";
|
||||
|
||||
// ─── Ctrl+V 주입 (ClipboardHistoryHandler와 동일 패턴) ────────────────────
|
||||
|
||||
private static void SendCtrlV()
|
||||
{
|
||||
const uint INPUT_KEYBOARD = 1;
|
||||
const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
const ushort VK_CONTROL = 0x11;
|
||||
const ushort VK_V = 0x56;
|
||||
|
||||
var inputs = new INPUT[4];
|
||||
inputs[0] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_CONTROL } };
|
||||
inputs[1] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_V } };
|
||||
inputs[2] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_V, dwFlags = KEYEVENTF_KEYUP } };
|
||||
inputs[3] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_CONTROL, dwFlags = KEYEVENTF_KEYUP } };
|
||||
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
}
|
||||
|
||||
// ─── P/Invoke ──────────────────────────────────────────────────────────────
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 40)]
|
||||
private struct INPUT
|
||||
{
|
||||
[FieldOffset(0)] public uint Type;
|
||||
[FieldOffset(8)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] pInputs, int cbSize);
|
||||
}
|
||||
|
||||
126
src/AxCopilot/Services/ForegroundPasteHelper.cs
Normal file
126
src/AxCopilot/Services/ForegroundPasteHelper.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
internal static class ForegroundPasteHelper
|
||||
{
|
||||
private const int SW_RESTORE = 9;
|
||||
private const uint INPUT_KEYBOARD = 1;
|
||||
private const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
private const ushort VK_CONTROL = 0x11;
|
||||
private const ushort VK_V = 0x56;
|
||||
|
||||
public static async Task<bool> RestoreWindowAsync(
|
||||
IntPtr hwnd,
|
||||
CancellationToken ct,
|
||||
int initialDelayMs = 220,
|
||||
int settleDelayMs = 60,
|
||||
int maxAttempts = 4)
|
||||
{
|
||||
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
|
||||
return false;
|
||||
|
||||
await Task.Delay(initialDelayMs, ct);
|
||||
|
||||
if (IsIconic(hwnd))
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
uint currentThread = GetCurrentThreadId();
|
||||
uint targetThread = GetWindowThreadProcessId(hwnd, out _);
|
||||
var foreground = GetForegroundWindow();
|
||||
uint foregroundThread = foreground != IntPtr.Zero
|
||||
? GetWindowThreadProcessId(foreground, out _)
|
||||
: 0;
|
||||
|
||||
var attachedTarget = false;
|
||||
var attachedForeground = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (targetThread != 0 && targetThread != currentThread)
|
||||
{
|
||||
AttachThreadInput(currentThread, targetThread, true);
|
||||
attachedTarget = true;
|
||||
}
|
||||
|
||||
if (foregroundThread != 0 && foregroundThread != currentThread && foregroundThread != targetThread)
|
||||
{
|
||||
AttachThreadInput(currentThread, foregroundThread, true);
|
||||
attachedForeground = true;
|
||||
}
|
||||
|
||||
BringWindowToTop(hwnd);
|
||||
SetForegroundWindow(hwnd);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (attachedForeground)
|
||||
AttachThreadInput(currentThread, foregroundThread, false);
|
||||
|
||||
if (attachedTarget)
|
||||
AttachThreadInput(currentThread, targetThread, false);
|
||||
}
|
||||
|
||||
await Task.Delay(settleDelayMs, ct);
|
||||
|
||||
if (GetForegroundWindow() == hwnd)
|
||||
return true;
|
||||
}
|
||||
|
||||
return GetForegroundWindow() == hwnd;
|
||||
}
|
||||
|
||||
public static async Task<bool> PasteClipboardAsync(
|
||||
IntPtr hwnd,
|
||||
CancellationToken ct,
|
||||
int initialDelayMs = 220)
|
||||
{
|
||||
var restored = await RestoreWindowAsync(hwnd, ct, initialDelayMs);
|
||||
if (!restored)
|
||||
return false;
|
||||
|
||||
await Task.Delay(40, ct);
|
||||
SendCtrlV();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void SendCtrlV()
|
||||
{
|
||||
var inputs = new INPUT[4];
|
||||
inputs[0] = new INPUT { Type = INPUT_KEYBOARD, Ki = new KEYBDINPUT { wVk = VK_CONTROL } };
|
||||
inputs[1] = new INPUT { Type = INPUT_KEYBOARD, Ki = new KEYBDINPUT { wVk = VK_V } };
|
||||
inputs[2] = new INPUT { Type = INPUT_KEYBOARD, Ki = new KEYBDINPUT { wVk = VK_V, dwFlags = KEYEVENTF_KEYUP } };
|
||||
inputs[3] = new INPUT { Type = INPUT_KEYBOARD, Ki = new KEYBDINPUT { wVk = VK_CONTROL, dwFlags = KEYEVENTF_KEYUP } };
|
||||
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 40)]
|
||||
private struct INPUT
|
||||
{
|
||||
[FieldOffset(0)] public uint Type;
|
||||
[FieldOffset(8)] public KEYBDINPUT Ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")] private static extern bool IsWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hWnd);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] pInputs, int cbSize);
|
||||
}
|
||||
Reference in New Issue
Block a user