import { Button } from "$components/Button";
import { ContextMenu, type ContextMenuDivider, type ContextMenuItem, useContextMenu } from "$components/ContextMenu";
import { CHROME_SECTION, NO_MOTION_TRANSITION, QUICK_CAPTURE } from "$constants";
import { ChevronDownIcon, PenIcon, XIcon } from "$icons";
import type { CaptureMode } from "$types";
import { formatShortcut } from "$utils/shortcuts";
import { cn } from "$utils/tw";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export type QuickCaptureSaveTarget = { id: string; locationId: number; relPath: string; label: string };
type QuickCaptureFormProps = {
defaultMode?: CaptureMode;
onSubmit: (text: string, mode: CaptureMode, destination?: { locationId: number; relPath: string }) => void;
onClose: () => void;
isSubmitting: boolean;
error: string | null;
reduceMotion?: boolean;
saveTargets?: QuickCaptureSaveTarget[];
};
type FormTransition = typeof CHROME_SECTION.TRANSITION | typeof NO_MOTION_TRANSITION;
type ModeButtonsProps = {
currentMode: CaptureMode;
setMode: (mode: CaptureMode) => void;
isSubmitting: boolean;
transition: FormTransition;
};
const MODE_OPTIONS = ["QuickNote", "WritingSession", "Append"] as const satisfies readonly CaptureMode[];
const EMPTY_SAVE_TARGETS: QuickCaptureSaveTarget[] = [];
const getModeLabel = (m: CaptureMode): string => {
switch (m) {
case "QuickNote":
return "Quick Note";
case "WritingSession":
return "Writing Session";
case "Append":
return "Append";
default:
return m;
}
};
const getModeDescription = (m: CaptureMode): string => {
switch (m) {
case "QuickNote":
return "Capture and save immediately into your inbox.";
case "WritingSession":
return "Draft freely and save when you are ready.";
case "Append":
return "Append your note to the last configured target.";
default:
return "Capture a thought quickly.";
}
};
const getShortcutHint = (mode: CaptureMode): string => {
if (mode === "WritingSession") {
return `${formatShortcut("Cmd+Enter")} to save, Esc to close`;
}
return "Enter to save, Shift+Enter for newline, Esc to close";
};
const ModeDescription = ({ mode, transition }: { mode: CaptureMode; transition: FormTransition }) => (
{getModeDescription(mode)}
);
const ModeButton = (
{ mode, currentMode, setMode, isSubmitting, transition }: {
mode: CaptureMode;
currentMode: CaptureMode;
setMode: (mode: CaptureMode) => void;
isSubmitting: boolean;
transition: FormTransition;
},
) => {
const handleClick = useCallback(() => {
setMode(mode);
}, [mode, setMode]);
const classes = useMemo(
() =>
cn(
"relative rounded px-2.5 py-1.5 text-xs font-medium transition-[color,background-color,opacity,border-color] duration-150 sm:text-sm",
mode === currentMode ? "text-text-primary" : "text-text-secondary hover:text-text-primary",
),
[mode, currentMode],
);
return (
);
};
const ModeButtons = ({ currentMode, setMode, isSubmitting, transition }: ModeButtonsProps) => (
{MODE_OPTIONS.map((modeOption) => (
))}
);
const CaptureTitle = ({ mode, transition }: { mode: CaptureMode; transition: FormTransition }) => (
);
const QuickCaptureFormHeader = (
{ onClose, currentMode, setMode, isSubmitting, transition }: ModeButtonsProps & { onClose: () => void },
) => (
);
const CaptureMetaRow = ({ mode, noteStats }: { mode: CaptureMode; noteStats: string }) => (
{getModeLabel(mode)} mode
{noteStats}
);
const CaptureTextarea = (
{ textareaRef, text, isSubmitting, onChange, onKeyDown }: {
textareaRef: React.RefObject;
text: string;
isSubmitting: boolean;
onChange: (e: React.ChangeEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
},
) => (
);
const ComposePane = (
{ mode, noteStats, text, isSubmitting, textareaRef, onTextChange, onTextareaKeyDown, transition }: {
mode: CaptureMode;
noteStats: string;
text: string;
isSubmitting: boolean;
textareaRef: React.RefObject;
onTextChange: (e: React.ChangeEvent) => void;
onTextareaKeyDown: (e: React.KeyboardEvent) => void;
transition: FormTransition;
},
) => (
);
function FooterStatus(
{ error, mode, transition, selectedSaveTargetLabel }: {
error: string | null;
mode: CaptureMode;
transition: FormTransition;
selectedSaveTargetLabel: string | null;
},
) {
const message = useMemo(
() => error ?? (selectedSaveTargetLabel ? `Save To: ${selectedSaveTargetLabel}` : getShortcutHint(mode)),
[error, selectedSaveTargetLabel, mode],
);
const statusClass = useMemo(() => error ? "text-support-error" : "text-text-placeholder", [error]);
const statusKey = useMemo(() => error ?? selectedSaveTargetLabel ?? mode, [error, selectedSaveTargetLabel, mode]);
return (
);
}
function FooterActions(
{ mode, isSubmitting, canSubmit, onSubmit, onClose, saveTargets, selectedSaveTargetId, onSaveTargetSelect }: {
mode: CaptureMode;
isSubmitting: boolean;
canSubmit: boolean;
onSubmit: () => void;
onClose: () => void;
saveTargets: QuickCaptureSaveTarget[];
selectedSaveTargetId: string | null;
onSaveTargetSelect: (targetId: string | null) => void;
},
) {
const { isOpen, position, openAt, close } = useContextMenu();
const isAppendMode = mode === "Append";
const canChooseSaveTarget = !isAppendMode && saveTargets.length > 0;
const menuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>(() => {
if (isAppendMode) {
return [{ label: "Append mode uses append target", onClick: () => {}, disabled: true }];
}
if (saveTargets.length === 0) {
return [{ label: "No directories found", onClick: () => {}, disabled: true }];
}
return [
{ label: "Save (Auto)", onClick: () => onSaveTargetSelect(null) },
{ divider: true },
...saveTargets.map((target) => ({
label: `Save to ${target.label}`,
onClick: () => onSaveTargetSelect(target.id),
})),
];
}, [isAppendMode, onSaveTargetSelect, saveTargets]);
const handleOpenSaveTo = useCallback((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
openAt(rect.left, rect.bottom + 6);
}, [openAt]);
const saveLabel = selectedSaveTargetId ? "Save To" : "Save Capture";
const saveButtonLabel = isSubmitting ? "Saving..." : saveLabel;
return (
<>
>
);
}
function SaveToMenuButton(
{ onOpen, disabled }: { onOpen: (event: React.MouseEvent) => void; disabled: boolean },
) {
return (
);
}
export function QuickCaptureForm(
{
defaultMode = "QuickNote",
onSubmit,
onClose,
isSubmitting,
error,
reduceMotion = false,
saveTargets = EMPTY_SAVE_TARGETS,
}: QuickCaptureFormProps,
) {
const [text, setText] = useState("");
const [mode, setMode] = useState(defaultMode);
const [selectedSaveTargetId, setSelectedSaveTargetId] = useState(null);
const textareaRef = useRef(null);
const transition = useMemo(() => reduceMotion ? NO_MOTION_TRANSITION : CHROME_SECTION.TRANSITION, [reduceMotion]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const canSubmit = useMemo(() => text.trim().length > 0, [text]);
const noteStats = useMemo(() => {
if (text.length === 0) {
return "0 lines · 0 chars";
}
const lineCount = text.split(/\r\n|\r|\n/).length;
return `${lineCount} line${lineCount > 1 ? "s" : ""} · ${text.length} chars`;
}, [text]);
const selectedSaveTarget = useMemo(() => saveTargets.find((target) => target.id === selectedSaveTargetId) ?? null, [
saveTargets,
selectedSaveTargetId,
]);
useEffect(() => {
if (selectedSaveTargetId === null) {
return;
}
if (!saveTargets.some((target) => target.id === selectedSaveTargetId)) {
setSelectedSaveTargetId(null);
}
}, [saveTargets, selectedSaveTargetId]);
const handleSubmit = useCallback(() => {
if (isSubmitting || !canSubmit) {
return;
}
if (mode !== "Append" && selectedSaveTarget) {
onSubmit(text, mode, { locationId: selectedSaveTarget.locationId, relPath: selectedSaveTarget.relPath });
return;
}
onSubmit(text, mode);
}, [text, mode, isSubmitting, canSubmit, onSubmit, selectedSaveTarget]);
const handleSaveTargetSelect = useCallback((targetId: string | null) => {
setSelectedSaveTargetId(targetId);
}, []);
const handleTextChange = useCallback((e: React.ChangeEvent) => {
setText(e.target.value);
}, []);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
if (!isSubmitting) {
onClose();
}
return;
}
const hasMod = e.metaKey || e.ctrlKey;
if (hasMod && e.key === "Enter") {
e.preventDefault();
handleSubmit();
return;
}
if (mode !== "WritingSession" && e.key === "Enter" && !hasMod && !e.shiftKey && !e.altKey) {
e.preventDefault();
handleSubmit();
}
}, [handleSubmit, isSubmitting, mode, onClose]);
return (
);
}