Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
import { resolve } from 'path';

const appPath = resolve(__dirname, '../out/main/index.js');
const UNIQUE = Date.now().toString(36);

async function launchApp(): Promise<{ app: ElectronApplication; window: Page }> {
const app = await electron.launch({ args: [appPath] });
const window = await app.firstWindow();
await window.waitForSelector('#root > *');
return { app, window };
}

test.describe('Context Menu', () => {
let app: ElectronApplication;
let window: Page;

const clipA = `ctx-clip-a-${UNIQUE}`;
const clipB = `ctx-clip-b-${UNIQUE}`;

test.beforeAll(async () => {
const result = await launchApp();
app = result.app;
window = result.window;

// Add two clips: clipA first, then clipB (clipB will be index 0, clipA index 1)
await app.evaluate(async ({ clipboard }, t) => {
clipboard.writeText(t);
}, clipA);
await window.waitForTimeout(1000);

await app.evaluate(async ({ clipboard }, t) => {
clipboard.writeText(t);
}, clipB);
await window.waitForTimeout(1000);

// Verify both clips appear
await expect(window.locator(`text=${clipA}`).first()).toBeVisible({ timeout: 5000 });
await expect(window.locator(`text=${clipB}`).first()).toBeVisible({ timeout: 5000 });
});

test.afterAll(async () => {
await app.close();
});

test('right-click on a non-first clip shows context menu with all options', async () => {
// clipA is at index 1 (non-first) — right-click it
await window.locator(`text=${clipA}`).first().click({ button: 'right' });

// Context menu should appear with all items
await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 });
await expect(window.locator('text=Open Tools Launcher')).toBeVisible();
await expect(window.locator('text=Lock Clip')).toBeVisible();
await expect(window.locator('text=Delete Clip')).toBeVisible();

// Close by pressing Escape
await window.keyboard.press('Escape');
});

test('right-click on a clip further down the list shows context menu fully visible', async () => {
// Add several more clips to push items down the list
for (let i = 0; i < 8; i++) {
await app.evaluate(
async ({ clipboard }, t) => {
clipboard.writeText(t);
},
`ctx-filler-${i}-${UNIQUE}`
);
await window.waitForTimeout(500);
}
await window.waitForTimeout(1000);

// Scroll to clipA (now further down) and right-click
const target = window.locator(`text=${clipA}`).first();
await target.scrollIntoViewIfNeeded();
await target.click({ button: 'right' });

// Context menu should be fully visible (portalled to body, not clipped by overflow)
await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 });
await expect(window.locator('text=Delete Clip')).toBeVisible();

await window.keyboard.press('Escape');
});

test('context menu closes when clicking elsewhere', async () => {
const target = window.locator(`text=${clipA}`).first();
await target.scrollIntoViewIfNeeded();
await target.click({ button: 'right' });

await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 });

// Click elsewhere to close
await window.locator('body').click({ position: { x: 5, y: 5 } });

await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 });
});

test('context menu closes on Escape key', async () => {
const target = window.locator(`text=${clipA}`).first();
await target.scrollIntoViewIfNeeded();
await target.click({ button: 'right' });

await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 });

await window.keyboard.press('Escape');

await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 });
});

test('Copy to Clipboard action works from context menu', async () => {
const target = window.locator(`text=${clipA}`).first();
await target.scrollIntoViewIfNeeded();
await target.click({ button: 'right' });

await expect(window.locator('text=Copy to Clipboard')).toBeVisible({ timeout: 3000 });
await window.locator('text=Copy to Clipboard').click();

// Menu should close after action
await expect(window.locator('text=Copy to Clipboard')).toBeHidden({ timeout: 3000 });

// Verify clipboard contains the expected text
const clipboardText = await app.evaluate(async ({ clipboard }) => {
return clipboard.readText();
});
expect(clipboardText).toBe(clipA);
});
});
36 changes: 22 additions & 14 deletions e2e/image-clipboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,49 @@ test.describe('Image Clipboard', () => {
const window = await app.firstWindow();
await window.waitForSelector('#root > *');

// Clear any persisted clips from previous test runs
await window.evaluate(async () => {
const api = (window as any).api;
await api.storageSaveClips([], {});
});
await window.waitForTimeout(500);

// Reload the window to pick up the cleared state
await window.reload();
await window.waitForSelector('#root > *');
await window.waitForTimeout(500);

const testImagePath = resolve(__dirname, 'fixtures/test-image.png');

// Copy first image
// Copy first image (original dimensions)
await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => {
const image = nativeImage.createFromPath(imgPath);
clipboard.writeImage(image);
}, testImagePath);

await window.waitForTimeout(2000);
await expect(window.locator('img[alt="Clipboard image preview"]').first()).toBeVisible({
timeout: 5000,
});
const imgPreviews = window.locator('img[alt="Clipboard image preview"]');
await expect(imgPreviews.first()).toBeVisible({ timeout: 5000 });

// Clear clipboard with text to reset, then copy a different image
// Verify exactly 1 image clip so far
await expect(imgPreviews).toHaveCount(1, { timeout: 3000 });

// Clear clipboard with text to reset the detection state
await app.evaluate(async ({ clipboard }) => {
clipboard.writeText('separator');
});
await window.waitForTimeout(1000);

// Copy image again (will have different fingerprint due to fresh nativeImage instance)
// Copy a different image (resized to different dimensions → stable, distinct fingerprint)
await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => {
// Create a slightly different image by modifying pixels
const image = nativeImage.createFromPath(imgPath);
const size = image.getSize();
const buf = image.toBitmap();
// Flip a pixel to ensure different fingerprint
buf[0] = buf[0] === 0 ? 1 : 0;
const modified = nativeImage.createFromBitmap(buf, { width: size.width, height: size.height });
clipboard.writeImage(modified);
const resized = image.resize({ width: 16, height: 16 });
clipboard.writeImage(resized);
}, testImagePath);

await window.waitForTimeout(2000);

// Should now have two image clips
const imgPreviews = window.locator('img[alt="Clipboard image preview"]');
await expect(imgPreviews).toHaveCount(2, { timeout: 5000 });

await app.close();
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "1.7.2",
"version": "1.7.3",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/components/clips/clip/ClipContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useClipsActions } from '../../../providers/clips';
import { useTheme } from '../../../providers/theme';
Expand Down Expand Up @@ -106,7 +107,7 @@ export function ClipContextMenu({ index, x, y, onClose, hasPatterns }: ClipConte
}
};

return (
return createPortal(
<div
ref={menuRef}
className={classNames(styles.contextMenu, { [styles.light]: isLight })}
Expand Down Expand Up @@ -151,6 +152,7 @@ export function ClipContextMenu({ index, x, y, onClose, hasPatterns }: ClipConte
<FontAwesomeIcon icon="trash" className={styles.menuIcon} />
<span>Delete Clip</span>
</div>
</div>
</div>,
document.body
);
}
Loading