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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user