feat: tabbed About dialog with keyboard shortcuts reference
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,43 @@
|
|||||||
let { onClose }: { onClose: () => void } = $props();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
let version = $state('');
|
let version = $state('');
|
||||||
|
let activeTab = $state<'about' | 'shortcuts'>('about');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
getVersion().then((v) => {
|
getVersion().then((v) => {
|
||||||
version = v;
|
version = v;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const globalShortcuts = [
|
||||||
|
{ key: 'Space', action: 'Play / Pause' },
|
||||||
|
{ key: 'K', action: 'Play / Pause' },
|
||||||
|
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
|
||||||
|
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
|
||||||
|
{ key: '←', action: 'Seek back 5s' },
|
||||||
|
{ key: '→', action: 'Seek forward 5s' },
|
||||||
|
{ key: 'Shift+←', action: 'Seek back 1s' },
|
||||||
|
{ key: 'Shift+→', action: 'Seek forward 1s' },
|
||||||
|
{ key: ',', action: 'Previous frame' },
|
||||||
|
{ key: '.', action: 'Next frame' },
|
||||||
|
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
||||||
|
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
||||||
|
{ key: 'C / P', action: 'Toggle mode' },
|
||||||
|
{ key: '⌘/', action: 'About' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const playerShortcuts = [
|
||||||
|
{ key: 'F', action: 'Toggle fullscreen' },
|
||||||
|
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
|
||||||
|
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const clipperShortcuts = [
|
||||||
|
{ key: 'I', action: 'Set in-point' },
|
||||||
|
{ key: 'O', action: 'Set out-point' },
|
||||||
|
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
|
||||||
|
{ key: '⌘E', action: 'Export' },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -26,62 +57,134 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="dialog"
|
class="dialog"
|
||||||
|
class:wide={activeTab === 'shortcuts'}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
<!-- Tab bar -->
|
||||||
<defs>
|
<div class="tab-bar" role="tablist">
|
||||||
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
<button
|
||||||
<stop offset="0%" stop-color="#fb923c"/>
|
class="tab"
|
||||||
<stop offset="100%" stop-color="#ea580c"/>
|
class:active={activeTab === 'about'}
|
||||||
</linearGradient>
|
role="tab"
|
||||||
</defs>
|
aria-selected={activeTab === 'about'}
|
||||||
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
onclick={() => { activeTab = 'about'; }}
|
||||||
<g transform="translate(-3, 3)">
|
type="button"
|
||||||
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
>About</button>
|
||||||
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
<button
|
||||||
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
class="tab"
|
||||||
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
class:active={activeTab === 'shortcuts'}
|
||||||
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
role="tab"
|
||||||
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
aria-selected={activeTab === 'shortcuts'}
|
||||||
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
onclick={() => { activeTab = 'shortcuts'; }}
|
||||||
</g>
|
type="button"
|
||||||
<g transform="translate(3, -3)">
|
>Shortcuts</button>
|
||||||
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
</div>
|
||||||
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
|
||||||
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
{#if activeTab === 'about'}
|
||||||
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
<!-- About content -->
|
||||||
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<div class="about-content">
|
||||||
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
<defs>
|
||||||
</g>
|
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||||
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
<stop offset="0%" stop-color="#fb923c"/>
|
||||||
<g transform="rotate(20, 256, 256)">
|
<stop offset="100%" stop-color="#ea580c"/>
|
||||||
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
</linearGradient>
|
||||||
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
</defs>
|
||||||
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||||
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
<g transform="translate(-3, 3)">
|
||||||
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||||
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
</g>
|
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
</svg>
|
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<h2>GUI Video Clipper</h2>
|
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
{#if version}
|
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
<span class="version">v{version}</span>
|
</g>
|
||||||
|
<g transform="translate(3, -3)">
|
||||||
|
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
||||||
|
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
</g>
|
||||||
|
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
||||||
|
<g transform="rotate(20, 256, 256)">
|
||||||
|
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
||||||
|
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
||||||
|
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
||||||
|
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
||||||
|
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||||
|
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||||
|
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<h2>GUI Video Clipper</h2>
|
||||||
|
{#if version}
|
||||||
|
<span class="version">v{version}</span>
|
||||||
|
{/if}
|
||||||
|
<p class="author">by cottongin</p>
|
||||||
|
<p class="license">MIT License</p>
|
||||||
|
<a
|
||||||
|
class="repo-link"
|
||||||
|
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Shortcuts content -->
|
||||||
|
<div class="shortcuts-content">
|
||||||
|
<section>
|
||||||
|
<h3>Global</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each globalShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Player Mode</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each playerShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Clipper Mode</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each clipperShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="author">by cottongin</p>
|
|
||||||
<p class="license">MIT License</p>
|
|
||||||
<a
|
|
||||||
class="repo-link"
|
|
||||||
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
code.cottongin.xyz/cottongin/gui-video-clipper
|
|
||||||
</a>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" onclick={onClose}>Close</button>
|
<button class="primary" onclick={onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +206,7 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px;
|
padding: 24px 32px 32px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -111,6 +214,53 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
transition: max-width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.wide {
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- About content --- */
|
||||||
|
.about-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon {
|
.app-icon {
|
||||||
@@ -153,6 +303,64 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Shortcuts content --- */
|
||||||
|
.shortcuts-content {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(70vh - 140px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-cell {
|
||||||
|
width: 40%;
|
||||||
|
padding: 5px 8px 5px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
padding: 5px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Actions --- */
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
59
tests/lib/components/AboutDialog.test.ts
Normal file
59
tests/lib/components/AboutDialog.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, fireEvent } from '@testing-library/svelte';
|
||||||
|
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/app', () => ({
|
||||||
|
getVersion: vi.fn().mockResolvedValue('0.2.1'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AboutDialog', () => {
|
||||||
|
it('renders the About tab by default', () => {
|
||||||
|
const { getByText } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||||
|
expect(getByText('by cottongin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows two tabs: About and Shortcuts', () => {
|
||||||
|
const { getByRole } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
const tablist = getByRole('tablist');
|
||||||
|
expect(tablist).toBeTruthy();
|
||||||
|
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Shortcuts tab on click', async () => {
|
||||||
|
const { getByText, getAllByText } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
const shortcutsTab = getByText('Shortcuts');
|
||||||
|
await fireEvent.click(shortcutsTab);
|
||||||
|
|
||||||
|
expect(getAllByText('Play / Pause').length).toBeGreaterThan(0);
|
||||||
|
expect(getByText('Toggle mode')).toBeTruthy();
|
||||||
|
expect(getByText('Player Mode')).toBeTruthy();
|
||||||
|
expect(getByText('Clipper Mode')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches back to About tab', async () => {
|
||||||
|
const { getByText, getByRole } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByRole('tab', { name: 'Shortcuts' }));
|
||||||
|
await fireEvent.click(getByRole('tab', { name: 'About' }));
|
||||||
|
|
||||||
|
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||||
|
expect(getByText('by cottongin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when Close button is clicked', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { getByText } = render(AboutDialog, {
|
||||||
|
props: { onClose },
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByText('Close'));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user