Fix context menu Pin/Delete and Control+click race.
Control+click was treated as multi-select and a window click listener dismissed the menu before Pin/Delete could run; fire menu actions on mousedown and close only on outside pointerdown.
This commit is contained in:
+3
-1
@@ -82,8 +82,10 @@ saved history entry is never modified.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Multi-select | `Cmd+Click` to toggle one, `Shift+Click`/`Shift+Arrow` for a range, `Cmd+A` for all |
|
| Multi-select | `Cmd+Click` to toggle one, `Shift+Click`/`Shift+Arrow` for a range, `Cmd+A` for all |
|
||||||
| Paste multiple selected items (joined with newlines) | `Enter` |
|
| Paste multiple selected items (joined with newlines) | `Enter` |
|
||||||
| Delete | `Backspace`/`Delete` with items selected |
|
| Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** |
|
||||||
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
|
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
|
||||||
|
|
||||||
|
On macOS, **Control+click** opens the context menu (same as right-click). Use **Cmd+Click** for multi-select — Control+click is not multi-select.
|
||||||
| Search | Just start typing — instant full-text search |
|
| Search | Just start typing — instant full-text search |
|
||||||
|
|
||||||
### What you'll see in the list
|
### What you'll see in the list
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ If the toggle fails:
|
|||||||
|
|
||||||
1. System Settings → General → Login Items → **+** → choose `maCopy.app`
|
1. System Settings → General → Login Items → **+** → choose `maCopy.app`
|
||||||
2. Or: right-click maCopy in the Dock (while open) → Options → **Open at Login**
|
2. Or: right-click maCopy in the Dock (while open) → Options → **Open at Login**
|
||||||
|
3. Or install a LaunchAgent (also restarts after crash/`kill`, but not after a normal Quit):
|
||||||
|
|
||||||
|
`~/Library/LaunchAgents/macopy.plist` → `/Applications/maCopy.app/Contents/MacOS/macopy`
|
||||||
|
with `RunAtLoad` + `KeepAlive` (`SuccessfulExit` = false).
|
||||||
|
|
||||||
## Requirements for it to stick
|
## Requirements for it to stick
|
||||||
|
|
||||||
|
|||||||
+69
-1
@@ -114,7 +114,7 @@ describe("App", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByText("Copy me"));
|
fireEvent.contextMenu(screen.getByText("Copy me"));
|
||||||
fireEvent.click(await screen.findByText("Copy"));
|
fireEvent.mouseDown(await screen.findByText("Copy"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("copy_to_clipboard", {
|
expect(mockInvoke).toHaveBeenCalledWith("copy_to_clipboard", {
|
||||||
@@ -125,6 +125,74 @@ describe("App", () => {
|
|||||||
expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything());
|
expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("invokes toggle_pin from the Pin context menu action", async () => {
|
||||||
|
const entries = [makeEntry({ content: "Pin me", pinned: false })];
|
||||||
|
mockTauriCommands(entries);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Pin me")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByText("Pin me"));
|
||||||
|
// Simulate the trailing click from a Control+click open — must not kill the menu.
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
});
|
||||||
|
fireEvent.click(document.body);
|
||||||
|
|
||||||
|
expect(screen.getByText("Pin")).toBeInTheDocument();
|
||||||
|
fireEvent.mouseDown(screen.getByText("Pin"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("toggle_pin", { id: entries[0].id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes delete_entry from the Delete context menu action", async () => {
|
||||||
|
const entries = [makeEntry({ content: "Trash me" })];
|
||||||
|
mockTauriCommands(entries);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Trash me")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByText("Trash me"));
|
||||||
|
fireEvent.mouseDown(await screen.findByText("Delete"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("delete_entry", { id: entries[0].id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the context menu open through the Control+click trailing click", async () => {
|
||||||
|
const entries = [makeEntry({ content: "Stay open" })];
|
||||||
|
mockTauriCommands(entries);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Stay open")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByText("Stay open"));
|
||||||
|
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Same-gesture primary click that used to dismiss via window click listener
|
||||||
|
fireEvent.click(screen.getByText("Stay open"));
|
||||||
|
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Outside press after the deferred listener attaches should close
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
});
|
||||||
|
fireEvent.pointerDown(document.body);
|
||||||
|
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows empty state text when there are no entries", async () => {
|
it("shows empty state text when there are no entries", async () => {
|
||||||
mockTauriCommands([]);
|
mockTauriCommands([]);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
+17
-4
@@ -315,11 +315,24 @@ export default function App() {
|
|||||||
pasteSingle, pasteEntries, handleDelete, selectOnly, selectRange,
|
pasteSingle, pasteEntries, handleDelete, selectOnly, selectRange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Close the menu on outside pointerdown. Defer listener attachment so the
|
||||||
|
// opening gesture (esp. macOS Control+click, which also fires a click) does
|
||||||
|
// not immediately dismiss the menu before Pin/Delete can be used.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const close = () => setContextMenu(null);
|
if (!contextMenu) return;
|
||||||
window.addEventListener("click", close);
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
return () => window.removeEventListener("click", close);
|
const t = e.target;
|
||||||
}, []);
|
if (t instanceof Element && t.closest("[data-context-menu]")) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
document.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
}, 0);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
};
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
const selectedCount = selectedIds.size;
|
const selectedCount = selectedIds.size;
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,23 @@ describe("ClipboardList", () => {
|
|||||||
expect(onCtrlClick).toHaveBeenCalledWith(0);
|
expect(onCtrlClick).toHaveBeenCalledWith(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not multi-select on Control+Click (macOS context-menu gesture)", () => {
|
||||||
|
const onCtrlClick = vi.fn();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const entries = [makeEntry({ content: "Control item" })];
|
||||||
|
render(
|
||||||
|
<ClipboardList
|
||||||
|
{...defaultProps}
|
||||||
|
entries={entries}
|
||||||
|
onCtrlClick={onCtrlClick}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.mouseDown(screen.getByText("Control item"), { ctrlKey: true, button: 0 });
|
||||||
|
expect(onCtrlClick).not.toHaveBeenCalled();
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onShiftClick when Shift+Click is used", () => {
|
it("calls onShiftClick when Shift+Click is used", () => {
|
||||||
const onShiftClick = vi.fn();
|
const onShiftClick = vi.fn();
|
||||||
const entries = [makeEntry({ content: "Shift item" })];
|
const entries = [makeEntry({ content: "Shift item" })];
|
||||||
|
|||||||
@@ -140,7 +140,13 @@ const Row = memo(function Row({
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Use mousedown so paste wins the race against window blur/hide.
|
// Use mousedown so paste wins the race against window blur/hide.
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (e.metaKey || e.ctrlKey) {
|
// Control+click is the macOS context-menu gesture — do not multi-select
|
||||||
|
// (Cmd+Click) or paste; let onContextMenu open the menu.
|
||||||
|
if (e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onCtrlClick(i);
|
onCtrlClick(i);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ describe("ContextMenu", () => {
|
|||||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onCopyOnly when Copy is clicked", () => {
|
it("calls onCopyOnly when Copy is pressed", () => {
|
||||||
const onCopyOnly = vi.fn();
|
const onCopyOnly = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onCopyOnly={onCopyOnly} />);
|
render(<ContextMenu {...defaultProps} onCopyOnly={onCopyOnly} />);
|
||||||
fireEvent.click(screen.getByText("Copy"));
|
fireEvent.mouseDown(screen.getByText("Copy"));
|
||||||
expect(onCopyOnly).toHaveBeenCalledOnce();
|
expect(onCopyOnly).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,41 +43,40 @@ describe("ContextMenu", () => {
|
|||||||
expect(screen.getByText("Delete 3 items")).toBeInTheDocument();
|
expect(screen.getByText("Delete 3 items")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPaste when Paste is clicked", () => {
|
it("calls onPaste when Paste is pressed", () => {
|
||||||
const onPaste = vi.fn();
|
const onPaste = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||||
fireEvent.click(screen.getByText("Paste"));
|
fireEvent.mouseDown(screen.getByText("Paste"));
|
||||||
expect(onPaste).toHaveBeenCalledWith("none");
|
expect(onPaste).toHaveBeenCalledWith("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPaste(plain) for Paste plain", () => {
|
it("calls onPaste(plain) for Paste plain", () => {
|
||||||
const onPaste = vi.fn();
|
const onPaste = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||||
fireEvent.click(screen.getByText("Paste plain"));
|
fireEvent.mouseDown(screen.getByText("Paste plain"));
|
||||||
expect(onPaste).toHaveBeenCalledWith("plain");
|
expect(onPaste).toHaveBeenCalledWith("plain");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPaste with transform id", () => {
|
it("calls onPaste with transform id", () => {
|
||||||
const onPaste = vi.fn();
|
const onPaste = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||||
fireEvent.click(screen.getByText("Pretty JSON"));
|
fireEvent.mouseDown(screen.getByText("Pretty JSON"));
|
||||||
expect(onPaste).toHaveBeenCalledWith("json");
|
expect(onPaste).toHaveBeenCalledWith("json");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPin when Pin is clicked", () => {
|
it("calls onPin when Pin is pressed", () => {
|
||||||
const onPin = vi.fn();
|
const onPin = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onPin={onPin} />);
|
render(<ContextMenu {...defaultProps} onPin={onPin} />);
|
||||||
fireEvent.click(screen.getByText("Pin"));
|
fireEvent.mouseDown(screen.getByText("Pin"));
|
||||||
expect(onPin).toHaveBeenCalledOnce();
|
expect(onPin).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onDelete when Delete is clicked", () => {
|
it("calls onDelete when Delete is pressed", () => {
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onDelete={onDelete} />);
|
render(<ContextMenu {...defaultProps} onDelete={onDelete} />);
|
||||||
fireEvent.click(screen.getByText("Delete"));
|
fireEvent.mouseDown(screen.getByText("Delete"));
|
||||||
expect(onDelete).toHaveBeenCalledOnce();
|
expect(onDelete).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is positioned at the given coordinates", () => {
|
it("is positioned at the given coordinates", () => {
|
||||||
const { container } = render(<ContextMenu {...defaultProps} x={200} y={150} />);
|
const { container } = render(<ContextMenu {...defaultProps} x={200} y={150} />);
|
||||||
const menu = container.firstChild as HTMLElement;
|
const menu = container.firstChild as HTMLElement;
|
||||||
|
|||||||
@@ -26,29 +26,44 @@ export default function ContextMenu({
|
|||||||
const adjustedY = Math.min(y, window.innerHeight - 280);
|
const adjustedY = Math.min(y, window.innerHeight - 280);
|
||||||
const multi = selectedCount > 1;
|
const multi = selectedCount > 1;
|
||||||
|
|
||||||
|
// Fire actions on mousedown (same race as list paste) so blur/hide cannot
|
||||||
|
// cancel the click before onClick runs.
|
||||||
|
const act =
|
||||||
|
(fn: () => void) =>
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-context-menu
|
||||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[180px] text-[13px] max-h-[70vh] overflow-y-auto"
|
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[180px] text-[13px] max-h-[70vh] overflow-y-auto"
|
||||||
style={{ left: adjustedX, top: adjustedY }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={() => onPaste("none")}
|
onMouseDown={act(() => onPaste("none"))}
|
||||||
>
|
>
|
||||||
{multi ? `Paste ${selectedCount} items` : "Paste"}
|
{multi ? `Paste ${selectedCount} items` : "Paste"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={() => onPaste("plain")}
|
onMouseDown={act(() => onPaste("plain"))}
|
||||||
>
|
>
|
||||||
Paste plain
|
Paste plain
|
||||||
<span className="float-right text-[10px] text-text-secondary ml-2">⌥ click</span>
|
<span className="float-right text-[10px] text-text-secondary ml-2">⌥ click</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={onCopyOnly}
|
onMouseDown={act(onCopyOnly)}
|
||||||
>
|
>
|
||||||
{multi ? `Copy ${selectedCount} items` : "Copy"}
|
{multi ? `Copy ${selectedCount} items` : "Copy"}
|
||||||
<span className="float-right text-[10px] text-text-secondary ml-2">no auto-paste</span>
|
<span className="float-right text-[10px] text-text-secondary ml-2">no auto-paste</span>
|
||||||
@@ -62,17 +77,19 @@ export default function ContextMenu({
|
|||||||
</div>
|
</div>
|
||||||
{TRANSFORMS.filter((t) => t.id !== "none" && t.id !== "plain").map((t) => (
|
{TRANSFORMS.filter((t) => t.id !== "none" && t.id !== "plain").map((t) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={() => onPaste(t.id)}
|
onMouseDown={act(() => onPaste(t.id))}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={onPin}
|
onMouseDown={act(onPin)}
|
||||||
>
|
>
|
||||||
{entry.pinned ? "Unpin" : "Pin"}
|
{entry.pinned ? "Unpin" : "Pin"}
|
||||||
</button>
|
</button>
|
||||||
@@ -80,8 +97,9 @@ export default function ContextMenu({
|
|||||||
)}
|
)}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="w-full text-left px-3 py-1.5 text-danger hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-danger hover:bg-surface-hover transition-colors"
|
||||||
onClick={onDelete}
|
onMouseDown={act(onDelete)}
|
||||||
>
|
>
|
||||||
{multi ? `Delete ${selectedCount} items` : "Delete"}
|
{multi ? `Delete ${selectedCount} items` : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user