feat(quickcapture): React UI with v0.2 recovery-friendly copy

Task 19 of the slice plan. Frameless dark card with:
- placeholder "지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
- hint "Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기"
- Ctrl/Cmd+Enter to submit (window.inkling.capture.submit then
  hide), Esc to cancel (with "이 한 줄을 흘려보낼까요?" confirm
  when text > 5 chars)
- clipboard image paste -> thumbnail strip with ArrayBuffer
  retained for submit
- fallback "저장에 실패했습니다. 다시 시도해주세요." in-card on
  IPC error (window stays open with content preserved)

api.ts wraps window.inkling.capture as the typed CaptureApi.
main.tsx mounts <App /> on #root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:12:53 +09:00
parent 7bd8276493
commit 7148ad0f17
4 changed files with 106 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { captureApi } from './api.js';
interface PastedImage { url: string; buffer: ArrayBuffer; }
export function App(): React.ReactElement {
const [text, setText] = useState('');
const [images, setImages] = useState<PastedImage[]>([]);
const [err, setErr] = useState<string | null>(null);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
const submit = useCallback(async () => {
setErr(null);
if (text.trim().length === 0 && images.length === 0) return;
try {
await captureApi.submit({ text, images: images.map((i) => i.buffer) });
setText(''); setImages([]); captureApi.hide();
} catch (e) { setErr('저장에 실패했습니다. 다시 시도해주세요.'); }
}, [text, images]);
const cancel = useCallback(() => {
if (text.trim().length > 5) {
const ok = window.confirm('이 한 줄을 흘려보낼까요?');
if (!ok) return;
}
setText(''); setImages([]); captureApi.hide();
}, [text]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [cancel, submit]);
const onPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(e.clipboardData.items);
const imgs = items.filter((i) => i.type.startsWith('image/'));
if (imgs.length === 0) return;
e.preventDefault();
for (const it of imgs) {
const blob = it.getAsFile();
if (!blob) continue;
const buffer = await blob.arrayBuffer();
const url = URL.createObjectURL(blob);
setImages((prev) => [...prev, { url, buffer }]);
}
}, []);
return (
<div className="card">
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
onPaste={onPaste}
placeholder="지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
/>
{images.length > 0 && (
<div className="thumbs">
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
</div>
)}
<div className="hint">Ctrl+Enter · Esc · </div>
{err && <div className="err">{err}</div>}
</div>
);
}

View File

@@ -0,0 +1,2 @@
import type { CaptureApi } from '@shared/types';
export const captureApi: CaptureApi = window.inkling.capture;

View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Inkling Capture</title>
<style>
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; }
body { -webkit-app-region: drag; }
.card {
-webkit-app-region: no-drag;
background: #1e1e24; color: #eee;
border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.4);
height: calc(100% - 24px); margin: 12px;
display: flex; flex-direction: column; padding: 12px;
}
textarea { flex: 1; background: transparent; color: inherit; border: none; outline: none; font-size: 14px; resize: none; }
.thumbs { display: flex; gap: 6px; }
.thumbs img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
.hint { color: #888; font-size: 11px; margin-top: 6px; }
.err { color: #ff6a6a; font-size: 11px; margin-top: 6px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/quickcapture/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);