feat(app): 활성 창 캡쳐·캡쳐 목적지 선택·단축키 Ctrl+Alt 재배치 + UX 손질
- Ctrl+Alt+A/Z 맨 앞 창 통째 캡쳐(DWM 확장 프레임 경계), S/X 드래그 캡쳐. A/S 새 대화, Z/X 마지막 대화 - 캡쳐 후 창을 확실히 앞으로(AttachThreadInput), capture.image 에 목적지 path 실어 그 Composer 만 소비 - 드래그 선택영역 원본 밝기·더블버퍼(번쩍임) — code-assistant-v2 복사본에서 이식 - 단축키 Ctrl+Alt+Q/W: Eclipse·ADT·SAP GUI 바인딩 목록 대조해 비어 있는 조합으로 - 버튼 cursor:pointer(Tailwind v4), 가운데 스피너, 스니펫→챗 버튼, 스니펫 검색창 높이 84→88 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2869985139
commit
47d31dbe06
@@ -0,0 +1,4 @@
|
||||
|
||||
# 빌드 산출·환경별 값
|
||||
2_frontend/tsconfig.tsbuildinfo
|
||||
2_frontend/.env.production
|
||||
@@ -18,7 +18,7 @@ export default function App() {
|
||||
return () => setBridgeNavigate(null)
|
||||
}, [navigate])
|
||||
|
||||
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용).
|
||||
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Alt+W 복귀용).
|
||||
useEffect(() => {
|
||||
reportRoute(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
@@ -30,35 +30,50 @@ describe("Composer 캡쳐 이미지 첨부", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
|
||||
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
|
||||
const { unmount } = render(<Composer onSend={() => {}} capturePath="/snap/new" />)
|
||||
act(() =>
|
||||
wv.emit({
|
||||
type: "capture.image",
|
||||
path: "/snap/new",
|
||||
dataUrl: "data:image/png;base64,abc",
|
||||
})
|
||||
)
|
||||
expect(screen.getAllByRole("img")).toHaveLength(1)
|
||||
|
||||
unmount()
|
||||
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
|
||||
render(<Composer onSend={() => {}} capturePath="/snap/new" />)
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0)
|
||||
})
|
||||
|
||||
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
|
||||
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
|
||||
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
|
||||
it("다른 대화용 캡쳐는 소비하지 않음", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
|
||||
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
|
||||
render(<Composer onSend={() => {}} capturePath="/snap/s/current" />)
|
||||
act(() =>
|
||||
wv.emit({
|
||||
type: "capture.image",
|
||||
path: "/snap/s/previous",
|
||||
dataUrl: "data:image/png;base64,xyz",
|
||||
})
|
||||
)
|
||||
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
|
||||
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
|
||||
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0)
|
||||
expect(consumePendingCaptureImage("/snap/s/previous")).toBe("data:image/png;base64,xyz")
|
||||
})
|
||||
|
||||
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
const onSend = vi.fn()
|
||||
render(<Composer onSend={onSend} acceptCapture />)
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" }))
|
||||
render(<Composer onSend={onSend} capturePath="/snap/new" />)
|
||||
act(() =>
|
||||
wv.emit({
|
||||
type: "capture.image",
|
||||
path: "/snap/new",
|
||||
dataUrl: "data:image/png;base64,eA==",
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "전송" }))
|
||||
|
||||
@@ -71,10 +86,16 @@ describe("Composer 캡쳐 이미지 첨부", () => {
|
||||
it("이미지는 최대 4장까지만 첨부한다", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
render(<Composer onSend={() => {}} acceptCapture />)
|
||||
render(<Composer onSend={() => {}} capturePath="/snap/new" />)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
|
||||
act(() =>
|
||||
wv.emit({
|
||||
type: "capture.image",
|
||||
path: "/snap/new",
|
||||
dataUrl: `data:image/png;base64,eA${i}=`,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(4)
|
||||
|
||||
@@ -12,10 +12,8 @@ interface Props {
|
||||
busy?: boolean
|
||||
onStop?: () => void
|
||||
placeholder?: string
|
||||
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
|
||||
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
|
||||
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
|
||||
acceptCapture?: boolean
|
||||
// 이 Composer가 받을 캡처 대상 route. 대상이 다르면 pending을 남겨 목적지 리마운트가 소비함.
|
||||
capturePath?: string
|
||||
}
|
||||
|
||||
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
|
||||
@@ -47,7 +45,7 @@ function imageFromDataUrl(data: string): SnapImageInput | null {
|
||||
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
|
||||
}
|
||||
|
||||
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
|
||||
export function Composer({ onSend, busy, onStop, placeholder, capturePath }: Props) {
|
||||
const [value, setValue] = useState("")
|
||||
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
|
||||
const [attachments, setAttachments] = useState<string[]>([])
|
||||
@@ -88,22 +86,18 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
// 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
|
||||
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
|
||||
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
|
||||
// 캡처 이미지 수신 — 목적지 route만 pending을 소비해 네비게이션 리마운트 race를 막음.
|
||||
useEffect(() => {
|
||||
if (!acceptCapture) return
|
||||
const pending = consumePendingCaptureImage()
|
||||
if (!capturePath) return
|
||||
const pending = consumePendingCaptureImage(capturePath)
|
||||
if (pending) addImage(pending)
|
||||
const onCapture = () => {
|
||||
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
|
||||
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
|
||||
const dataUrl = consumePendingCaptureImage()
|
||||
const dataUrl = consumePendingCaptureImage(capturePath)
|
||||
if (dataUrl) addImage(dataUrl)
|
||||
}
|
||||
window.addEventListener("bridge:captureImage", onCapture)
|
||||
return () => window.removeEventListener("bridge:captureImage", onCapture)
|
||||
}, [acceptCapture])
|
||||
}, [capturePath])
|
||||
|
||||
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
|
||||
const removeImageAttachment = (i: number) =>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
|
||||
<ClipboardHistory />
|
||||
|
||||
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="bg-success size-1.5 rounded-full" />
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
|
||||
Source Nav
|
||||
</span>
|
||||
@@ -79,7 +79,7 @@ export function NavRail({ blocks, containerRef }: Props) {
|
||||
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
|
||||
>
|
||||
{copiedIdx === b.index ? (
|
||||
<Check className="size-3 text-success" />
|
||||
<Check className="text-success size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function NewChatPage() {
|
||||
onSend={start}
|
||||
busy={pending}
|
||||
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
|
||||
acceptCapture
|
||||
capturePath={PATHS.SNAP_NEW}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -223,6 +223,7 @@ export default function SessionChatPage() {
|
||||
busy={busy || generatingRemotely}
|
||||
onStop={stop}
|
||||
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
|
||||
capturePath={`/snap/s/${id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
@@ -67,7 +68,11 @@ afterEach(() => {
|
||||
|
||||
describe("새 스니펫 단축키", () => {
|
||||
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(window, { key: "2", ctrlKey: true })
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
@@ -79,7 +84,11 @@ describe("새 스니펫 단축키", () => {
|
||||
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
|
||||
vi.useFakeTimers()
|
||||
createSnippet.mockImplementation((_input, options) => options.onSuccess())
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const search = screen.getByRole("textbox")
|
||||
fireEvent.change(search, { target: { value: "기존 검색" } })
|
||||
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
|
||||
@@ -104,7 +113,11 @@ describe("스니펫 Enter 복사", () => {
|
||||
})
|
||||
)
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
|
||||
fireEvent.click(screen.getByText("B"))
|
||||
fireEvent.keyDown(window, { key: "Enter" })
|
||||
@@ -122,7 +135,11 @@ describe("스니펫 Enter 복사", () => {
|
||||
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("denied"))
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
|
||||
fireEvent.keyDown(window, { key: "ArrowDown" })
|
||||
fireEvent.keyDown(window, { key: "Enter", isComposing: true })
|
||||
@@ -136,7 +153,11 @@ describe("스니펫 Enter 복사", () => {
|
||||
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
|
||||
await user.click(screen.getByRole("button", { name: "B" }))
|
||||
const copy = screen.getByRole("button", { name: "복사" })
|
||||
@@ -154,7 +175,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
|
||||
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
await user.type(screen.getByRole("textbox"), "B")
|
||||
await user.click(screen.getByRole("button", { name: "B" }))
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true })
|
||||
@@ -169,7 +194,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
|
||||
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
await user.type(screen.getByRole("textbox"), "B")
|
||||
await user.click(screen.getByRole("button", { name: "B" }))
|
||||
screen.getByRole("button", { name: "복사" }).focus()
|
||||
@@ -188,7 +217,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
|
||||
|
||||
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const input = screen.getByRole("textbox")
|
||||
fireEvent.change(input, { target: { value: "B" } })
|
||||
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
|
||||
@@ -208,7 +241,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
|
||||
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
|
||||
vi.mocked(isWebView).mockReturnValue(false)
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText")
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const input = screen.getByRole("textbox")
|
||||
fireEvent.change(input, { target: { value: "B" } })
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" })
|
||||
@@ -220,7 +257,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
|
||||
|
||||
describe("검색 후 선택할 때만 미리보기", () => {
|
||||
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const input = screen.getByRole("textbox")
|
||||
expect(screen.queryByText("A")).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
|
||||
@@ -244,7 +285,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
|
||||
|
||||
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText")
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
|
||||
fireEvent.keyDown(window, { key: "Enter" })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
@@ -259,7 +304,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
|
||||
|
||||
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SnippetPalettePage />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnippetPalettePage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const input = screen.getByRole("textbox")
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
await user.type(input, "A")
|
||||
|
||||
@@ -2,7 +2,10 @@ import { hostKind, send } from "@/lib/bridge/transport"
|
||||
import { LogoMark } from "@/shared/components/LogoMark"
|
||||
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check, Search } from "lucide-react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Check, MessageSquare, Search } from "lucide-react"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { Kbd } from "@/shared/components/Kbd"
|
||||
import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
|
||||
import { toast } from "sonner"
|
||||
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
|
||||
@@ -25,6 +28,7 @@ import type { Snippet, SnippetInput } from "../types"
|
||||
const ALL_CATEGORY = "전체"
|
||||
|
||||
export default function SnippetPalettePage() {
|
||||
const navigate = useNavigate()
|
||||
const [q, setQ] = useState("")
|
||||
const { data: snippets = [], isLoading, error } = useSnippets()
|
||||
const recordUse = useRecordUse()
|
||||
@@ -307,6 +311,14 @@ export default function SnippetPalettePage() {
|
||||
placeholder="스니펫 검색…"
|
||||
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none"
|
||||
/>
|
||||
{/* 챗으로 전환 — 같은 창에서 route 만 바뀜(Ctrl+Alt+W 와 동일) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(PATHS.SNAP)}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex h-7 flex-none items-center gap-1 rounded-md px-2 text-[11.5px] whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<MessageSquare className="size-3.5" aria-hidden="true" />챗<Kbd>Ctrl+Alt+W</Kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}
|
||||
|
||||
@@ -29,15 +29,20 @@ describe("bridgeNavigate", () => {
|
||||
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" })
|
||||
await emit("bridge", { type: "capture.image", dataUrl: "data:image/png;base64,abc" })
|
||||
await emit("bridge", {
|
||||
type: "capture.image",
|
||||
path: "/snap/new",
|
||||
dataUrl: "data:image/png;base64,abc",
|
||||
})
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/snippet")
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1)
|
||||
expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" })
|
||||
expect(onPasteTarget).toHaveBeenCalledTimes(1)
|
||||
expect(onCapture).toHaveBeenCalledTimes(1)
|
||||
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc")
|
||||
expect(consumePendingCaptureImage()).toBe("")
|
||||
expect(consumePendingCaptureImage("/snap/s/old")).toBe("")
|
||||
expect(consumePendingCaptureImage("/snap/new")).toBe("data:image/png;base64,abc")
|
||||
expect(consumePendingCaptureImage("/snap/new")).toBe("")
|
||||
|
||||
window.removeEventListener("bridge:navigate", onNavigate)
|
||||
window.removeEventListener("bridge:pasteTarget", onPasteTarget)
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface PasteTarget {
|
||||
|
||||
let navigateFn: NavigateFn | null = null
|
||||
let lastPasteTarget: PasteTarget = { name: "", app: "" }
|
||||
let pendingCaptureImage = ""
|
||||
let pendingCaptureImage: { path: string; dataUrl: string } | null = null
|
||||
|
||||
/** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */
|
||||
export function setBridgeNavigate(fn: NavigateFn | null) {
|
||||
@@ -21,10 +21,11 @@ export function getLastPasteTarget(): PasteTarget {
|
||||
return lastPasteTarget
|
||||
}
|
||||
|
||||
/** 마지막 캡처 이미지를 한 번만 소비함. */
|
||||
export function consumePendingCaptureImage(): string {
|
||||
const dataUrl = pendingCaptureImage
|
||||
pendingCaptureImage = ""
|
||||
/** 지정한 대화에 도착한 마지막 캡처 이미지를 한 번만 소비함. */
|
||||
export function consumePendingCaptureImage(path: string): string {
|
||||
if (pendingCaptureImage?.path !== path) return ""
|
||||
const { dataUrl } = pendingCaptureImage
|
||||
pendingCaptureImage = null
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
@@ -41,11 +42,9 @@ function handleBridgeMessage(data: {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === "capture.image") {
|
||||
pendingCaptureImage = data.dataUrl ?? ""
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } })
|
||||
)
|
||||
if (data.type === "capture.image" && data.path) {
|
||||
pendingCaptureImage = { path: data.path, dataUrl: data.dataUrl ?? "" }
|
||||
window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: pendingCaptureImage }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense } from "react"
|
||||
import { CenterSpinner } from "@/shared/components/CenterSpinner"
|
||||
import { Navigate, type RouteObject } from "react-router-dom"
|
||||
import Layout from "@/shared/components/Layout"
|
||||
import ProtectedRoute from "@/shared/components/ProtectedRoute"
|
||||
@@ -12,7 +13,7 @@ const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
|
||||
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
|
||||
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
|
||||
|
||||
const fallback = <div className="text-muted-foreground">로딩...</div>
|
||||
const fallback = <CenterSpinner />
|
||||
|
||||
const wrap = (el: React.ReactNode) => <Suspense fallback={fallback}>{el}</Suspense>
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
/** 화면 정가운데 도는 스피너 — 페이지 lazy 로딩·로그인 확인 같은 전체 화면 대기용. */
|
||||
export function CenterSpinner({ label = "로딩 중" }: { label?: string }) {
|
||||
return (
|
||||
<div role="status" aria-label={label} className="flex h-dvh w-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { useMe } from "@/features/auth/hooks/useMe"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { CenterSpinner } from "@/shared/components/CenterSpinner"
|
||||
|
||||
/**
|
||||
* 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다.
|
||||
@@ -25,7 +26,7 @@ export default function ProtectedRoute() {
|
||||
return <Navigate to={`${PATHS.LOGIN}${query}`} replace />
|
||||
}
|
||||
if (me.isLoading) {
|
||||
return <div className="text-muted-foreground p-6">로그인 확인 중...</div>
|
||||
return <CenterSpinner label="로그인 확인 중" />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
/* Tailwind v4 가 버튼 기본 cursor 를 default 로 바꿔서 — 클릭되는 건 전부 손가락 모양 */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
a[href],
|
||||
label[for],
|
||||
select:not(:disabled),
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 스크롤바·선택·캐럿 — ABAP_OPENCODE 와 같은 얇은 오버레이 스타일 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -29,6 +29,7 @@ png = "0.17"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.61.3", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_DataExchange",
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::shell::{paste, paste::PasteState, window};
|
||||
pub struct Routes {
|
||||
pub current: String,
|
||||
pub last_snap: String,
|
||||
/// 마지막으로 열었던 기존 대화(`/snap/s/<id>`). 캡쳐를 기존 대화에 붙일 때 씀.
|
||||
pub last_session: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Routes {
|
||||
@@ -26,6 +28,7 @@ impl Default for Routes {
|
||||
Self {
|
||||
current: "/snap".into(),
|
||||
last_snap: "/snap".into(),
|
||||
last_session: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +38,9 @@ impl Routes {
|
||||
if path == "/snap" || path.starts_with("/snap/") {
|
||||
path.clone_into(&mut self.last_snap);
|
||||
}
|
||||
if path.starts_with("/snap/s/") {
|
||||
self.last_session = Some(path.to_string());
|
||||
}
|
||||
path.clone_into(&mut self.current);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +73,8 @@ pub enum SnippetStage {
|
||||
#[tauri::command]
|
||||
pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> {
|
||||
let (width, height) = match stage {
|
||||
SnippetStage::Search => (640.0, 84.0),
|
||||
// 제목줄 h-8(32) + 검색줄 h-14(56) = 88. 이보다 작으면 프레임 overflow-auto 가 스크롤바를 띄움.
|
||||
SnippetStage::Search => (640.0, 88.0),
|
||||
SnippetStage::Results => (640.0, 440.0),
|
||||
SnippetStage::Preview => (840.0, 520.0),
|
||||
SnippetStage::Editor => (960.0, 600.0),
|
||||
|
||||
@@ -30,9 +30,10 @@ pub enum Push {
|
||||
#[serde(rename = "paste.target")]
|
||||
PasteTarget { name: String, app: String },
|
||||
|
||||
/// 영역 캡쳐 PNG를 프론트의 새 대화 Composer에 붙임.
|
||||
/// 영역 캡쳐 PNG를 지정한 대화 Composer에 붙임.
|
||||
#[serde(rename = "capture.image")]
|
||||
CaptureImage {
|
||||
path: String,
|
||||
#[serde(rename = "dataUrl")]
|
||||
data_url: String,
|
||||
},
|
||||
|
||||
@@ -18,15 +18,29 @@ use shell::window;
|
||||
const HOTKEYS: &[HotkeySpec] = &[
|
||||
HotkeySpec {
|
||||
id: "snap",
|
||||
accelerator: "Ctrl+Shift+8",
|
||||
accelerator: "Ctrl+Alt+W",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "snippet",
|
||||
accelerator: "Ctrl+Shift+7",
|
||||
accelerator: "Ctrl+Alt+Q",
|
||||
},
|
||||
// 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화).
|
||||
// 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역.
|
||||
HotkeySpec {
|
||||
id: "capture",
|
||||
accelerator: "Ctrl+Shift+9",
|
||||
accelerator: "Ctrl+Alt+A",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_area",
|
||||
accelerator: "Ctrl+Alt+S",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_last",
|
||||
accelerator: "Ctrl+Alt+Z",
|
||||
},
|
||||
HotkeySpec {
|
||||
id: "capture_area_last",
|
||||
accelerator: "Ctrl+Alt+X",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -149,6 +163,35 @@ fn show_palette(app: &AppHandle) -> tauri::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화.
|
||||
fn capture_target(id: &str, last_session: Option<&str>) -> String {
|
||||
if id.ends_with("_last") {
|
||||
last_session.unwrap_or("/snap/new").to_string()
|
||||
} else {
|
||||
"/snap/new".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부.
|
||||
fn on_captured(app: AppHandle, target: String, data_url: String) {
|
||||
if let Err(error) = show_palette(&app) {
|
||||
eprintln!("[capture] 창 소환 실패: {error}");
|
||||
}
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::Navigate {
|
||||
path: target.clone(),
|
||||
},
|
||||
);
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::CaptureImage {
|
||||
path: target,
|
||||
data_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
|
||||
fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
match id {
|
||||
@@ -162,17 +205,20 @@ fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
"capture" => {
|
||||
shell::capture::start(app.clone(), |app, data_url| {
|
||||
let _ = show_palette(&app);
|
||||
bridge::push(
|
||||
&app,
|
||||
Push::Navigate {
|
||||
path: "/snap/new".into(),
|
||||
},
|
||||
);
|
||||
bridge::push(&app, Push::CaptureImage { data_url });
|
||||
});
|
||||
"capture" | "capture_area" | "capture_last" | "capture_area_last" => {
|
||||
let target = app
|
||||
.state::<commands::RouteState>()
|
||||
.lock()
|
||||
.map(|routes| capture_target(id, routes.last_session.as_deref()))
|
||||
.unwrap_or_else(|_| "/snap/new".to_string());
|
||||
let deliver =
|
||||
move |app: AppHandle, data_url: String| on_captured(app, target, data_url);
|
||||
if id.starts_with("capture_area") {
|
||||
shell::capture::start(app.clone(), deliver);
|
||||
} else {
|
||||
let own_hwnd = window::native_handle(app);
|
||||
shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver);
|
||||
}
|
||||
}
|
||||
|
||||
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
|
||||
@@ -199,7 +245,7 @@ fn on_hotkey(app: &AppHandle, id: &str) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{chat_toggle_target, commands::Routes};
|
||||
use super::{capture_target, chat_toggle_target, commands::Routes};
|
||||
|
||||
#[test]
|
||||
fn 챗봇_토글은_현재_화면과_창_표시에_따라_결정한다() {
|
||||
@@ -219,13 +265,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 마지막_챗봇_위치는_챗봇_화면에서만_바뀐다() {
|
||||
fn 마지막_기존_대화는_새_대화나_다른_화면으로_가도_유지된다() {
|
||||
let mut routes = Routes::default();
|
||||
routes.report("/snap/session-1");
|
||||
routes.report("/snippet");
|
||||
routes.report("/snap/s/session-1");
|
||||
routes.report("/snap/new");
|
||||
routes.report("/files");
|
||||
|
||||
assert_eq!(routes.current, "/files");
|
||||
assert_eq!(routes.last_snap, "/snap/session-1");
|
||||
assert_eq!(routes.last_snap, "/snap/new");
|
||||
assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 캡쳐_핫키에_따라_새_대화나_마지막_기존_대화를_고른다() {
|
||||
assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new");
|
||||
assert_eq!(
|
||||
capture_target("capture_area", Some("/snap/s/last")),
|
||||
"/snap/new"
|
||||
);
|
||||
assert_eq!(
|
||||
capture_target("capture_last", Some("/snap/s/last")),
|
||||
"/snap/s/last"
|
||||
);
|
||||
assert_eq!(capture_target("capture_area_last", None), "/snap/new");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
@@ -8,27 +9,30 @@ use base64::Engine;
|
||||
use tauri::AppHandle;
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC,
|
||||
DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC,
|
||||
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ, PAINTSTRUCT,
|
||||
SRCCOPY,
|
||||
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, HGDIOBJ,
|
||||
PAINTSTRUCT, SRCCOPY,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetMessageW,
|
||||
GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetForegroundWindow,
|
||||
GetMessageW, GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
|
||||
SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow,
|
||||
TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA,
|
||||
MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOW,
|
||||
WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN,
|
||||
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, WNDCLASSW,
|
||||
WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
|
||||
LWA_COLORKEY, MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN, SW_SHOW, WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY,
|
||||
WM_ERASEBKGND, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE,
|
||||
WM_NCDESTROY, WM_PAINT, WNDCLASSW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
|
||||
};
|
||||
|
||||
const MIN_SELECTION: i32 = 4;
|
||||
const OVERLAY_ALPHA: u8 = 115;
|
||||
// 선택 영역에만 칠해 layered window에서 완전히 투명하게 만드는 색.
|
||||
const SELECTION_COLOR_KEY: COLORREF = COLORREF(0x00ff00ff);
|
||||
// Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임.
|
||||
const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814);
|
||||
static CAPTURING: AtomicBool = AtomicBool::new(false);
|
||||
@@ -54,17 +58,36 @@ fn selection_rect(start: (i32, i32), end: (i32, i32), origin: (i32, i32)) -> Opt
|
||||
})
|
||||
}
|
||||
|
||||
/// 이미 캡쳐 중이면 두 번째 핫키는 무시함.
|
||||
/// 드래그로 영역 골라서 캡쳐. 이미 캡쳐 중이면 두 번째 핫키는 무시함.
|
||||
pub fn start<F>(app: AppHandle, on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
spawn_capture(app, on_captured, run_overlay);
|
||||
}
|
||||
|
||||
/// 핫키 누른 순간 맨 앞에 떠 있는 창을 통째로 캡쳐. 앱 종류 안 가림(SAP·Eclipse·브라우저 다 됨).
|
||||
/// 자기 창(팔레트)이 맨 앞이면 아무것도 안 함.
|
||||
pub fn start_foreground_window<F>(app: AppHandle, excluded_hwnd: Option<isize>, on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
spawn_capture(app, on_captured, move || {
|
||||
foreground_window_rect(excluded_hwnd)
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_capture<F, P>(app: AppHandle, on_captured: F, pick_rect: P)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
P: FnOnce() -> Result<Option<CaptureRect>, String> + Send + 'static,
|
||||
{
|
||||
if CAPTURING.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_overlay().and_then(|rect| match rect {
|
||||
let result = pick_rect().and_then(|rect| match rect {
|
||||
Some(rect) => capture_png_data_url(rect),
|
||||
None => Ok(None),
|
||||
});
|
||||
@@ -77,10 +100,59 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
struct PaintBuffer {
|
||||
dc: HDC,
|
||||
bitmap: HBITMAP,
|
||||
previous: HGDIOBJ,
|
||||
}
|
||||
|
||||
impl PaintBuffer {
|
||||
unsafe fn new(hwnd: HWND, width: i32, height: i32) -> Option<Self> {
|
||||
let window_dc = GetDC(Some(hwnd));
|
||||
if window_dc.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
let dc = CreateCompatibleDC(Some(window_dc));
|
||||
let bitmap = CreateCompatibleBitmap(window_dc, width, height);
|
||||
let _ = ReleaseDC(Some(hwnd), window_dc);
|
||||
if dc.is_invalid() || bitmap.is_invalid() {
|
||||
if !dc.is_invalid() {
|
||||
let _ = DeleteDC(dc);
|
||||
}
|
||||
if !bitmap.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(bitmap.0));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let previous = SelectObject(dc, HGDIOBJ(bitmap.0));
|
||||
if previous.is_invalid() {
|
||||
let _ = DeleteObject(HGDIOBJ(bitmap.0));
|
||||
let _ = DeleteDC(dc);
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
dc,
|
||||
bitmap,
|
||||
previous,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PaintBuffer {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
SelectObject(self.dc, self.previous);
|
||||
let _ = DeleteObject(HGDIOBJ(self.bitmap.0));
|
||||
let _ = DeleteDC(self.dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayState {
|
||||
origin: (i32, i32),
|
||||
start: Option<(i32, i32)>,
|
||||
current: Option<(i32, i32)>,
|
||||
buffer: Option<PaintBuffer>,
|
||||
sender: mpsc::Sender<Option<CaptureRect>>,
|
||||
finished: bool,
|
||||
}
|
||||
@@ -106,6 +178,24 @@ fn mouse_point(lparam: LPARAM) -> (i32, i32) {
|
||||
((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32)
|
||||
}
|
||||
|
||||
fn selection_client_rect(start: (i32, i32), end: (i32, i32)) -> RECT {
|
||||
RECT {
|
||||
left: start.0.min(end.0),
|
||||
top: start.1.min(end.1),
|
||||
right: start.0.max(end.0),
|
||||
bottom: start.1.max(end.1),
|
||||
}
|
||||
}
|
||||
|
||||
fn union_rect(a: RECT, b: RECT) -> RECT {
|
||||
RECT {
|
||||
left: a.left.min(b.left),
|
||||
top: a.top.min(b.top),
|
||||
right: a.right.max(b.right),
|
||||
bottom: a.bottom.max(b.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
|
||||
let inset = RECT {
|
||||
left: rect.left + amount,
|
||||
@@ -116,6 +206,28 @@ fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
|
||||
(inset.left < inset.right && inset.top < inset.bottom).then_some(inset)
|
||||
}
|
||||
|
||||
unsafe fn draw_overlay(dc: HDC, background: &RECT, selected: Option<RECT>) {
|
||||
let shade = CreateSolidBrush(COLORREF(0x000000));
|
||||
FillRect(dc, background, shade);
|
||||
let _ = DeleteObject(HGDIOBJ(shade.0));
|
||||
|
||||
let Some(selected) = selected else { return };
|
||||
let clear = CreateSolidBrush(SELECTION_COLOR_KEY);
|
||||
FillRect(dc, &selected, clear);
|
||||
let _ = DeleteObject(HGDIOBJ(clear.0));
|
||||
|
||||
let white = CreateSolidBrush(COLORREF(0x00ffffff));
|
||||
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
|
||||
FrameRect(dc, &selected, white);
|
||||
for amount in 1..4 {
|
||||
if let Some(border) = inset_rect(selected, amount) {
|
||||
FrameRect(dc, &border, accent);
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(white.0));
|
||||
let _ = DeleteObject(HGDIOBJ(accent.0));
|
||||
}
|
||||
|
||||
unsafe extern "system" fn overlay_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
@@ -141,9 +253,13 @@ unsafe extern "system" fn overlay_proc(
|
||||
}
|
||||
WM_MOUSEMOVE => {
|
||||
if let Some(state) = state {
|
||||
if state.start.is_some() {
|
||||
state.current = Some(mouse_point(lparam));
|
||||
let _ = InvalidateRect(Some(hwnd), None, false);
|
||||
if let Some(start) = state.start {
|
||||
let current = mouse_point(lparam);
|
||||
let old = selection_client_rect(start, state.current.unwrap_or(start));
|
||||
let new = selection_client_rect(start, current);
|
||||
state.current = Some(current);
|
||||
let dirty = union_rect(old, new);
|
||||
let _ = InvalidateRect(Some(hwnd), Some(&dirty), false);
|
||||
}
|
||||
}
|
||||
LRESULT(0)
|
||||
@@ -172,33 +288,34 @@ unsafe extern "system" fn overlay_proc(
|
||||
WM_PAINT => {
|
||||
let mut paint = PAINTSTRUCT::default();
|
||||
let dc = BeginPaint(hwnd, &mut paint);
|
||||
let mut client = RECT::default();
|
||||
let _ = GetClientRect(hwnd, &mut client);
|
||||
let shade = CreateSolidBrush(COLORREF(0x000000));
|
||||
FillRect(dc, &client, shade);
|
||||
if let Some(state) = state {
|
||||
if let (Some(start), Some(end)) = (state.start, state.current) {
|
||||
let selected = RECT {
|
||||
left: start.0.min(end.0),
|
||||
top: start.1.min(end.1),
|
||||
right: start.0.max(end.0),
|
||||
bottom: start.1.max(end.1),
|
||||
};
|
||||
// 반투명 오버레이에서도 경계가 묻히지 않게 흰색 외곽선과
|
||||
// 포인트색 안쪽선을 겹쳐 4px 테두리로 그림.
|
||||
let white = CreateSolidBrush(COLORREF(0x00ffffff));
|
||||
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
|
||||
FrameRect(dc, &selected, white);
|
||||
for amount in 1..4 {
|
||||
if let Some(border) = inset_rect(selected, amount) {
|
||||
FrameRect(dc, &border, accent);
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(white.0));
|
||||
let _ = DeleteObject(HGDIOBJ(accent.0));
|
||||
let selected = state.as_ref().and_then(|state| {
|
||||
state
|
||||
.start
|
||||
.zip(state.current)
|
||||
.map(|(start, end)| selection_client_rect(start, end))
|
||||
});
|
||||
|
||||
if let Some(buffer) = state.as_ref().and_then(|state| state.buffer.as_ref()) {
|
||||
draw_overlay(buffer.dc, &paint.rcPaint, selected);
|
||||
let width = paint.rcPaint.right - paint.rcPaint.left;
|
||||
let height = paint.rcPaint.bottom - paint.rcPaint.top;
|
||||
if width > 0 && height > 0 {
|
||||
let _ = BitBlt(
|
||||
dc,
|
||||
paint.rcPaint.left,
|
||||
paint.rcPaint.top,
|
||||
width,
|
||||
height,
|
||||
Some(buffer.dc),
|
||||
paint.rcPaint.left,
|
||||
paint.rcPaint.top,
|
||||
SRCCOPY,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
draw_overlay(dc, &paint.rcPaint, selected);
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(shade.0));
|
||||
|
||||
let _ = EndPaint(hwnd, &paint);
|
||||
LRESULT(0)
|
||||
}
|
||||
@@ -246,6 +363,7 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
origin: (x, y),
|
||||
start: None,
|
||||
current: None,
|
||||
buffer: None,
|
||||
sender,
|
||||
finished: false,
|
||||
});
|
||||
@@ -270,8 +388,13 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(error) = SetLayeredWindowAttributes(hwnd, COLORREF(0), OVERLAY_ALPHA, LWA_ALPHA)
|
||||
{
|
||||
(*state_ptr).buffer = PaintBuffer::new(hwnd, width, height);
|
||||
if let Err(error) = SetLayeredWindowAttributes(
|
||||
hwnd,
|
||||
SELECTION_COLOR_KEY,
|
||||
OVERLAY_ALPHA,
|
||||
LWA_ALPHA | LWA_COLORKEY,
|
||||
) {
|
||||
let _ = DestroyWindow(hwnd);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
@@ -288,6 +411,30 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
Ok(receiver.recv().ok().flatten())
|
||||
}
|
||||
|
||||
/// 맨 앞 창의 화면 좌표. Win11 은 GetWindowRect 가 그림자 여백까지 잡아서 DWM 확장 프레임 경계를 씀.
|
||||
fn foreground_window_rect(excluded_hwnd: Option<isize>) -> Result<Option<CaptureRect>, String> {
|
||||
let window = unsafe { GetForegroundWindow() };
|
||||
if window.0.is_null() || excluded_hwnd == Some(window.0 as isize) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bounds = RECT::default();
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
window,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
&mut bounds as *mut RECT as *mut c_void,
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("창 경계를 못 얻었어: {error}"))?;
|
||||
Ok(Some(CaptureRect {
|
||||
x: bounds.left,
|
||||
y: bounds.top,
|
||||
width: bounds.right - bounds.left,
|
||||
height: bounds.bottom - bounds.top,
|
||||
}))
|
||||
}
|
||||
|
||||
fn capture_png_data_url(rect: CaptureRect) -> Result<Option<String>, String> {
|
||||
if rect.width <= 0 || rect.height <= 0 {
|
||||
return Ok(None);
|
||||
@@ -413,4 +560,21 @@ mod tests {
|
||||
assert_eq!((inset.right, inset.bottom), (27, 47));
|
||||
assert!(inset_rect(rect, 10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 이동한_선택_영역의_합집합만_다시_그린다() {
|
||||
let old = selection_client_rect((100, 100), (300, 250));
|
||||
let new = selection_client_rect((100, 100), (320, 220));
|
||||
let dirty = union_rect(old, new);
|
||||
|
||||
assert_eq!(
|
||||
dirty,
|
||||
RECT {
|
||||
left: 100,
|
||||
top: 100,
|
||||
right: 320,
|
||||
bottom: 250,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,10 @@ where
|
||||
{
|
||||
eprintln!("[capture] 영역 캡처는 Windows 에서만 됨");
|
||||
}
|
||||
|
||||
pub fn start_foreground_window<F>(_app: AppHandle, _excluded_hwnd: Option<isize>, _on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
eprintln!("[capture] 창 캡처는 Windows 에서만 됨");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,17 @@ use std::sync::Mutex;
|
||||
use tauri::{
|
||||
AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Foundation::HWND;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
BringWindowToTop, GetForegroundWindow, GetWindowThreadProcessId, SetForegroundWindow,
|
||||
ShowWindow, SW_RESTORE,
|
||||
};
|
||||
|
||||
/// tauri.conf.json 의 창 label 과 같아야 함.
|
||||
const MAIN: &str = "main";
|
||||
@@ -19,7 +30,6 @@ pub fn main_window(app: &AppHandle) -> Option<WebviewWindow> {
|
||||
app.get_webview_window(MAIN)
|
||||
}
|
||||
|
||||
/// 창 표시 + 포커스. (핫키/트레이 소환용)
|
||||
/// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND).
|
||||
/// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함.
|
||||
pub fn native_handle(app: &AppHandle) -> Option<isize> {
|
||||
@@ -36,10 +46,45 @@ pub fn native_handle(app: &AppHandle) -> Option<isize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 창 표시 + 포커스. (핫키/트레이 소환용)
|
||||
/// Windows 에선 캡처 오버레이가 돌려준 foreground 를 현재 입력 큐에 잠시 붙여
|
||||
/// 이미 보이던 창도 뒤에 남지 않게 직접 활성화함(Tauri set_focus 만으론 외부 앱이 다시 가져감).
|
||||
pub fn show(app: &AppHandle) -> tauri::Result<()> {
|
||||
if let Some(w) = main_window(app) {
|
||||
w.show()?;
|
||||
w.set_focus()?;
|
||||
let _ = w.set_focus();
|
||||
#[cfg(windows)]
|
||||
force_foreground(&w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn force_foreground(w: &WebviewWindow) -> tauri::Result<()> {
|
||||
let raw = w.hwnd()?;
|
||||
let hwnd = HWND(raw.0 as *mut std::ffi::c_void);
|
||||
unsafe {
|
||||
let foreground = GetForegroundWindow();
|
||||
let current_thread = GetCurrentThreadId();
|
||||
let foreground_thread = if foreground.0.is_null() {
|
||||
0
|
||||
} else {
|
||||
GetWindowThreadProcessId(foreground, None)
|
||||
};
|
||||
let attached = foreground_thread != 0
|
||||
&& foreground_thread != current_thread
|
||||
&& AttachThreadInput(current_thread, foreground_thread, true).as_bool();
|
||||
|
||||
let _ = ShowWindow(hwnd, SW_RESTORE);
|
||||
let _ = BringWindowToTop(hwnd);
|
||||
if !SetForegroundWindow(hwnd).as_bool() {
|
||||
eprintln!("[window] foreground 활성화 실패");
|
||||
}
|
||||
let _ = SetFocus(Some(hwnd));
|
||||
|
||||
if attached {
|
||||
let _ = AttachThreadInput(current_thread, foreground_thread, false);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 2026-09-17 단축키는 왼손 한손으로
|
||||
|
||||
## 원문
|
||||
|
||||
> 단축키를 숫자로 하지말고 왼손 한손으로 끝내고 싶음. 지금은 컨트롤 쉬프트는 왼손 숫자는 오른손이라 불편함
|
||||
|
||||
## 왜 틀렸나
|
||||
|
||||
`Ctrl+Shift+7/8/9` 는 왼손으로 modifier 잡고 오른손으로 숫자 눌러야 해서 두 손이 필요함. SAP GUI·Eclipse 에서 마우스 잡은 채로 호출하는 게 이 앱의 핵심 흐름이라, 한손(왼손)으로 끝나야 함.
|
||||
|
||||
## 앞으로
|
||||
|
||||
- 전역 단축키는 **왼손 범위 글자**(Q W E R A S D F Z X C V)만 씀. 숫자·오른손 글자 금지.
|
||||
- modifier 는 `Ctrl+Alt`. 2026-09-17 실제 목록으로 확인한 결과:
|
||||
- `Ctrl+Shift+글자`: Eclipse/ADT 가 거의 다 씀(A 오브젝트 열기, R/E/W/F/L/G…).
|
||||
- `Alt+Shift+글자`: 역시 Eclipse 가 씀 — A 블록선택, S Source, W Show In, Z Surround With, X Run As, D Debug As, R Rename, T Refactor, Q Show View. SAP GUI 도 Alt+Shift+V(디버거 변수).
|
||||
- `Ctrl+Alt+글자`: Eclipse 는 G/H/M/N/B 만, SAP GUI 는 L/M/P 만 씀 → Q/W/A/S/Z/X 는 비어 있음.
|
||||
- 새 키 잡을 땐 Eclipse 에서 `Ctrl+Shift+L` 로 실제 바인딩 목록 보고 겹치는지 확인할 것. 기억으로 판단 금지.
|
||||
- 현재 배정: `Ctrl+Alt+Q` 스니펫 / `Ctrl+Alt+W` 챗 토글 / `Ctrl+Alt+A` 맨 앞 창 캡쳐→새 대화 / `Ctrl+Alt+S` 드래그 캡쳐→새 대화 / `Ctrl+Alt+Z` 맨 앞 창 캡쳐→마지막 대화 / `Ctrl+Alt+X` 드래그 캡쳐→마지막 대화. 윗줄 = 여는 것, A/S = 새 대화에 찍기, Z/X = 기존 대화에 찍기.
|
||||
- 진실원천은 `4_rust_tauri/src-tauri/src/lib.rs` 의 `HOTKEYS`. 새 단축키 추가할 때도 이 규칙 따름.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 캡처가 뒤쪽 창에 붙고 대상 대화를 고를 수 없음
|
||||
|
||||
## 증상
|
||||
|
||||
- 새 대화 화면이 이미 열려 있어도 캡처 완료 뒤 CodeAssist가 앞으로 오지 않고 뒤에서 이미지만 첨부됐음.
|
||||
- 캡처 이미지는 항상 새 대화로 보내져 기존 대화에 붙일 방법이 없었음.
|
||||
|
||||
## 원인
|
||||
|
||||
- 캡처 완료 흐름이 `window.show()`와 Tauri `set_focus()` 결과에만 기대고 있었음. Win32 캡처 오버레이가 닫힌 뒤 외부 앱이 다시 foreground를 가져가면 이미 보이는 CodeAssist 창의 활성화가 보장되지 않았음.
|
||||
- native 흐름이 대상 route를 무조건 `/snap/new`로 보냈음.
|
||||
- 프론트의 pending 캡처는 대상 route 정보가 없어 `NewChatPage`만 소비하도록 막아둔 상태였음.
|
||||
|
||||
## 수정
|
||||
|
||||
- 창을 소환할 때 현재 입력 thread를 foreground 입력 thread에 잠시 연결하고 Win32 `BringWindowToTop`, `SetForegroundWindow`, `SetFocus`로 메인 창을 직접 활성화함.
|
||||
- `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 함. 기존 대화가 없으면 새 대화로 대체함.
|
||||
- `capture.image`에 대상 `path`를 추가하고, path가 같은 Composer만 pending 이미지를 소비하게 바꿈.
|
||||
|
||||
## 확인
|
||||
|
||||
- 대상 route 선택과 route별 pending 소비 회귀 테스트를 RED→GREEN으로 확인함.
|
||||
- frontend 전체 217개 테스트, lint, build와 Rust 13개 테스트, build를 실행함.
|
||||
- 데스크톱 앱에서 `/snap/new` 도착과 캡처 첨부 노출을 확인함.
|
||||
- 자동화 환경이 Windows 창 focus 전환을 지원하지 않아 외부 앱 위로 실제 foreground가 바뀌는 장면은 자동 확인하지 못했음. 해당 한 항목은 실제 단축키 수동 확인이 남음.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 캡처 선택 영역이 드래그 중 번쩍임
|
||||
|
||||
## 증상
|
||||
|
||||
- 캡처 범위를 드래그하면 원래 화면으로 보이는 선택 영역이 계속 번쩍였음.
|
||||
- 캡처 결과가 아니라 드래그 중 오버레이 표시에서만 발생했음.
|
||||
|
||||
## 원인
|
||||
|
||||
- `WM_MOUSEMOVE`마다 `InvalidateRect`에 영역을 주지 않아 전체 가상 화면을 다시 그렸음.
|
||||
- `WM_PAINT`가 실제 화면 DC에 검정 배경, 투명 color key, 테두리를 차례로 직접 그려 중간 상태가 노출됐음.
|
||||
- 멀티모니터 전체 면적을 매번 다시 칠해 불필요한 GDI 작업도 컸음.
|
||||
|
||||
## 수정
|
||||
|
||||
- 이전 선택 사각형과 새 선택 사각형의 합집합만 무효화함.
|
||||
- 오버레이 크기의 호환 bitmap과 memory DC를 한 번 만들고 창이 닫힐 때 해제함.
|
||||
- memory DC에서 배경·투명 영역·테두리를 완성한 뒤 dirty 영역만 `BitBlt` 한 번으로 화면에 복사함.
|
||||
- memory DC를 만들지 못하면 기존 화면 DC 그리기로 대체함.
|
||||
|
||||
## 확인
|
||||
|
||||
- 역방향 드래그를 포함한 선택 영역 합집합 계산 회귀 테스트를 추가함.
|
||||
- Rust 테스트 14개와 build를 통과함.
|
||||
- 요청에 따라 computer-use는 사용하지 않았고, 실제 드래그 화면 확인은 사용자 수동 확인으로 남김.
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
| 방향 | 메시지 | 처리 |
|
||||
|---|---|---|
|
||||
| C#→JS | `{type:"capture.image", dataUrl}` | 프론트가 캡쳐 이미지를 챗 입력창 첨부로 받음 |
|
||||
| C#→JS | `{type:"capture.image", path, dataUrl}` | 프론트가 캡쳐 이미지를 지정된 챗 입력창 첨부로 받음 |
|
||||
|
||||
- `path`: 캡쳐를 받을 route(`/snap/new` 또는 `/snap/s/:id`).
|
||||
- `dataUrl`: `data:image/png;base64,...` (PNG).
|
||||
- App 캡쳐 완료 순서: `ShowPalette()` → `NavigateApp("/snap/new")` → `SendCaptureImage(dataUrl)`. (navigate·소환은 기존 배선.)
|
||||
- App 캡쳐 완료 순서: 창 소환 → 대상 route로 이동 → `{type:"capture.image", path, dataUrl}` 전송.
|
||||
- 기존 메시지(navigate·paste.target·window.*·snippets.* 등)와 같은 리스너 허브에서 `type`으로 분기.
|
||||
|
||||
## C# 표면 (신규/확장)
|
||||
@@ -22,14 +23,15 @@
|
||||
## 프론트 계약 (`bridgeNavigate.ts`)
|
||||
|
||||
```ts
|
||||
// 마지막 캡쳐 이미지 dataUrl 을 1회 소비(마운트 레이스 대비). 없으면 "".
|
||||
export function consumePendingCaptureImage(): string
|
||||
// capture.image 수신 → pendingCaptureImage 갱신 + window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: { dataUrl } }))
|
||||
// path가 일치하는 마지막 캡쳐 이미지를 1회 소비하고, 다르면 pending을 유지함.
|
||||
export function consumePendingCaptureImage(path: string): string
|
||||
// capture.image 수신 → {path, dataUrl} 보관 + bridge:captureImage 이벤트 발생
|
||||
```
|
||||
|
||||
## 프론트 계약 (`Composer.tsx`)
|
||||
|
||||
- 상태 `imageAttachments: string[]`(dataUrl). 마운트 시 `consumePendingCaptureImage()` push + `bridge:captureImage` 구독 push. X 제거. (data-model.md 규칙.)
|
||||
- `capturePath`와 메시지 `path`가 같은 Composer만 pending 이미지를 소비함. 네비게이션 직전의 다른 Composer가 이미지를 훔치지 않는다.
|
||||
- 새 대화와 기존 대화 모두 캡쳐 이미지를 누적하고 X로 제거할 수 있다.
|
||||
- v1 전송: 텍스트만. 이미지 첨부는 미전송 + 전송 후 정리.
|
||||
|
||||
## 순수 브라우저(dev)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
작업 중 화면 어디든 전역 핫키로 사각형을 그려 그 영역을 캡쳐하면, 그 이미지가 곧바로 챗 입력창에 첨부로 붙는다. 나중에 "이미지 놓고 AI랑 얘기"하려는 건데, **이번 v1은 캡쳐해서 챗에 붙이는 데까지**만 한다. 실제 분석(비전 LLM)·웹서치는 백엔드가 준비되면 그 위에 얹는다.
|
||||
|
||||
**한 줄 흐름**: 핫키 → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 챗 새 대화에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑).
|
||||
**한 줄 흐름**: 새 대화 핫키(`Ctrl+Shift+9`) 또는 마지막 대화 핫키(`Ctrl+Shift+0`) → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 선택한 챗 입력창에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑).
|
||||
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
### User Story 1 - 화면 캡쳐해서 챗에 첨부 (Priority: P1)
|
||||
|
||||
에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키(`Ctrl+Shift+9`)를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. 원하는 영역을 마우스로 드래그해 사각형으로 잡고 놓으면, 앱 창이 떠서 새 대화 화면으로 가고 그 캡쳐 이미지가 입력창에 첨부(썸네일)로 붙어 있다. 이제 질문을 타이핑하면 된다.
|
||||
에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화에 붙이고 기존 대화가 없으면 새 대화로 간다. 영역을 마우스로 드래그해 놓으면 앱 창이 앞으로 뜨고 선택한 대화 입력창에 캡쳐 이미지가 첨부돼 있다.
|
||||
|
||||
**Why this priority**: 이게 이 기능의 전부(v1). 캡쳐→첨부가 매끄럽게 되면 나중에 백엔드만 붙이면 "이미지로 AI와 대화"가 완성됨.
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 아무 창이 떠있음, **When** `Ctrl+Shift+9`, **Then** 모든 모니터를 덮는 어두운 캡쳐 오버레이가 뜬다.
|
||||
2. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다.
|
||||
3. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음).
|
||||
4. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다.
|
||||
5. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다.
|
||||
6. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지).
|
||||
2. **Given** 캡쳐 오버레이에서 영역을 드래그 중임, **When** 선택 사각형이 바뀜, **Then** 사각형 내부는 원래 화면 밝기로 보이고 바깥만 어둡게 유지된다.
|
||||
3. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다.
|
||||
4. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음).
|
||||
5. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다.
|
||||
6. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다.
|
||||
7. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지).
|
||||
|
||||
---
|
||||
|
||||
@@ -56,12 +57,13 @@
|
||||
|
||||
**캡쳐 트리거·오버레이**
|
||||
- **FR-001**: 전역 핫키 `Ctrl+Shift+9`로 화면 캡쳐 모드를 켤 수 있어야 한다. 기존 핫키(스니펫 `Ctrl+Shift+7`, 챗 `Ctrl+Shift+8`)와 별개다.
|
||||
- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 그 위에서 마우스 드래그로 선택 사각형을 그린다.
|
||||
- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 마우스 드래그 중 선택 사각형 내부는 원래 화면 밝기로 투명하게 보여준다.
|
||||
- **FR-003**: `Esc`, 또는 유효하지 않은 선택(드래그 없이 클릭/너무 작은 영역)이면 아무것도 캡쳐하지 않고 오버레이를 닫는다.
|
||||
|
||||
**캡쳐·전달**
|
||||
- **FR-004**: 드래그로 확정된 영역의 화면 픽셀을 이미지로 캡쳐한다.
|
||||
- **FR-005**: 캡쳐 완료 시 앱 창을 소환하고, **새 대화 화면**으로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다.
|
||||
- **FR-005**: 캡쳐 완료 시 앱 창을 앞으로 소환하고, 사용한 핫키에 맞는 대화로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다.
|
||||
- **FR-005a**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 하며 기존 대화가 없으면 새 대화로 대체한다.
|
||||
|
||||
**챗 첨부(끝점)**
|
||||
- **FR-006**: 전달된 이미지는 챗 입력창에 **썸네일 첨부**로 표시된다.
|
||||
@@ -90,7 +92,7 @@
|
||||
## Assumptions
|
||||
|
||||
- **v1 범위 = 캡쳐 파이프라인만.** 이미지를 실은 메시지를 멀티모달 LLM 백엔드로 보내 **비전 분석·응답**하는 것, **웹서치** 툴은 다음 phase(백엔드 계약 나올 때). **전송-with-이미지 payload 배선도 v1 밖** — v1의 전송은 기존 텍스트 그대로.
|
||||
- **캡쳐 도착지 = 새 대화 화면.** 캡쳐하면 새 챗을 열고 거기에 붙인다(기존 세션에 붙이는 건 v1 밖).
|
||||
- **캡쳐 도착지**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화. 캡쳐 메시지에 대상 route를 실어 해당 Composer만 이미지를 소비한다.
|
||||
- **재사용**: 기존 멀티핫키 등록, C#→JS 브릿지(SendToWeb/PostWebMessageAsJson), 팔레트 창 소환·navigate, 브릿지 이벤트 패턴(pasteTarget/bridgeNavigate 식 — 마운트 레이스 대비 모듈 보관+이벤트), Composer의 첨부 칩 UI 패턴을 최대한 얹는다. 캡쳐 오버레이 창과 캡쳐 로직만 실질 신규.
|
||||
- **현재 이미지 처리와의 관계**: 지금 이미지 붙여넣기는 클립보드 이력 사이드바로만 감(챗에 "보내는 첨부" 개념 없음). 이 기능이 **챗 입력창 이미지 첨부**를 처음 도입한다.
|
||||
- **ponytail 천장(추후 보완)**: ① 고DPI/모니터별 배율 좌표 보정, ② 큰 영역 캡쳐 시 base64가 커지면 임시파일/blob 전달로 최적화.
|
||||
@@ -99,5 +101,4 @@
|
||||
|
||||
- 이미지 비전 분석·AI 판단·응답(멀티모달 백엔드).
|
||||
- 웹서치.
|
||||
- 이미지를 실은 메시지의 백엔드 전송(payload 포맷 포함).
|
||||
- 기존(진행 중) 세션에 캡쳐 붙이기, 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.
|
||||
- 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.
|
||||
|
||||
Reference in New Issue
Block a user