Initial commit to new repository
This commit is contained in:
216
src/AxCopilot/Handlers/ClipboardHistoryHandler.cs
Normal file
216
src/AxCopilot/Handlers/ClipboardHistoryHandler.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 클립보드 히스토리 핸들러. "#" 프리픽스로 사용합니다.
|
||||
/// 예: # (빈 쿼리) → 최근 클립보드 목록
|
||||
/// # hello → "hello"가 포함된 항목 필터
|
||||
/// </summary>
|
||||
public class ClipboardHistoryHandler : IActionHandler
|
||||
{
|
||||
private readonly ClipboardHistoryService _historyService;
|
||||
|
||||
public string? Prefix => "#";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"ClipboardHistory",
|
||||
"클립보드 히스토리 — # 뒤에 검색어 (또는 빈 입력으로 전체 보기)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public ClipboardHistoryHandler(ClipboardHistoryService historyService)
|
||||
{
|
||||
_historyService = historyService;
|
||||
}
|
||||
|
||||
// 카테고리 필터 프리픽스: #url, #코드, #경로
|
||||
private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "url", "URL" }, { "코드", "코드" }, { "code", "코드" },
|
||||
{ "경로", "경로" }, { "path", "경로" }, { "핀", "핀" }, { "pin", "핀" },
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var history = _historyService.History;
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
"클립보드 히스토리가 없습니다",
|
||||
"텍스트를 복사하면 이 곳에 기록됩니다",
|
||||
null, null, Symbol: Symbols.History)
|
||||
]);
|
||||
}
|
||||
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
|
||||
// 카테고리 필터 감지 (예: #url, #핀)
|
||||
string? catFilter = null;
|
||||
foreach (var (prefix, cat) in CategoryFilters)
|
||||
{
|
||||
if (q == prefix || q.StartsWith(prefix + " "))
|
||||
{
|
||||
catFilter = cat;
|
||||
q = q.Length > prefix.Length ? q[(prefix.Length + 1)..].Trim() : "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var items = sorted
|
||||
.Select(e =>
|
||||
{
|
||||
var pinMark = e.IsPinned ? "\uD83D\uDCCC " : "";
|
||||
var catMark = e.Category != "일반" ? $"[{e.Category}] " : "";
|
||||
return new LauncherItem(
|
||||
$"{pinMark}{e.Preview}",
|
||||
$"{catMark}{e.RelativeTime} · {e.CopiedAt:MM/dd HH:mm}",
|
||||
null,
|
||||
e,
|
||||
Symbol: e.IsPinned ? Symbols.Favorite : (e.IsText ? Symbols.History : Symbols.Picture));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{query}'에 해당하는 항목 없음",
|
||||
"#pin #url #코드 #경로 로 필터링 가능",
|
||||
null, null, Symbol: Symbols.History));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is not ClipboardEntry entry) return;
|
||||
|
||||
try
|
||||
{
|
||||
_historyService.SuppressNextCapture();
|
||||
_historyService.PromoteEntry(entry); // 사용 시각 갱신 + 목록 맨 위로
|
||||
|
||||
if (!entry.IsText && entry.Image != null)
|
||||
{
|
||||
// 원본 이미지가 있으면 원본 해상도로 클립보드 복사
|
||||
var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath);
|
||||
Clipboard.SetImage(originalImg ?? entry.Image);
|
||||
return; // 이미지는 붙여넣기 시뮬레이션 없이 클립보드만 설정
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Text)) return;
|
||||
Clipboard.SetText(entry.Text);
|
||||
|
||||
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();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user