660 lines
19 KiB
Svelte
660 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { streamUrl } from '$lib/utils/tauri';
|
|
import { app } from '$lib/stores/app.svelte';
|
|
import { formatTimecode, formatTimecodeShort } from '$lib/utils/format';
|
|
// SegmentedControl replaced with custom toggle
|
|
|
|
interface Props {
|
|
currentTime: number;
|
|
onSeek: (time: number) => void;
|
|
}
|
|
|
|
let { currentTime, onSeek }: Props = $props();
|
|
|
|
let trackRef: HTMLDivElement | undefined = $state();
|
|
let innerRef: HTMLDivElement | undefined = $state();
|
|
let dragging: 'in' | 'out' | 'scrub' | null = $state(null);
|
|
let hoverX: number | null = $state(null);
|
|
let hoverTime: number | null = $state(null);
|
|
let zoomLevel = $state(1);
|
|
let animating = $state(false); // true when trim handles should animate (button clicks, not drags)
|
|
|
|
let inInput = $state('');
|
|
let outInput = $state('');
|
|
|
|
function parseTimecode(str: string): number {
|
|
const m = str.trim().match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
|
if (!m) return -1;
|
|
const h = parseInt(m[1]);
|
|
const min = parseInt(m[2]);
|
|
const sec = parseInt(m[3]);
|
|
const ms = m[4] ? parseInt(m[4].padEnd(3, '0')) : 0;
|
|
return h * 3600 + min * 60 + sec + ms / 1000;
|
|
}
|
|
|
|
function commitIn() {
|
|
const t = parseTimecode(inInput);
|
|
if (t >= 0 && t < outTime) {
|
|
app.setTrimRange(Math.max(0, t), outTime);
|
|
}
|
|
inInput = formatTimecode(inTime);
|
|
}
|
|
|
|
function commitOut() {
|
|
const t = parseTimecode(outInput);
|
|
if (t > inTime && t <= duration) {
|
|
app.setTrimRange(inTime, t);
|
|
}
|
|
outInput = formatTimecode(outTime);
|
|
}
|
|
|
|
function handleInKey(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
|
}
|
|
|
|
function handleOutKey(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
|
}
|
|
|
|
// keep input values in sync when trim changes externally (drag etc)
|
|
$effect(() => { inInput = formatTimecode(inTime); });
|
|
$effect(() => { outInput = formatTimecode(outTime); });
|
|
|
|
let duration = $derived(app.videoInfo?.duration ?? 0);
|
|
let keyframes = $derived(app.videoInfo?.keyframe_times ?? []);
|
|
|
|
let inTime = $derived(app.trimRange?.start ?? 0);
|
|
let outTime = $derived(app.trimRange?.end ?? duration);
|
|
let inPct = $derived(duration > 0 ? (inTime / duration) * 100 : 0);
|
|
let outPct = $derived(duration > 0 ? (outTime / duration) * 100 : 100);
|
|
let playheadPct = $derived(duration > 0 ? (currentTime / duration) * 100 : 0);
|
|
let selectedDuration = $derived(outTime - inTime);
|
|
|
|
let trimModeLabel = $derived(app.trimMode === 'keyframe' ? 'Keyframe snap' : 'Smart cut');
|
|
let modeOptions = ['Keyframe snap', 'Smart cut'];
|
|
|
|
function pctFromEvent(e: MouseEvent): number {
|
|
if (!trackRef || !innerRef) return 0;
|
|
const rect = trackRef.getBoundingClientRect();
|
|
const scrollLeft = trackRef.scrollLeft;
|
|
const innerWidth = innerRef.offsetWidth;
|
|
const x = e.clientX - rect.left + scrollLeft;
|
|
return Math.max(0, Math.min(100, (x / innerWidth) * 100));
|
|
}
|
|
|
|
function timeFromPct(pct: number): number {
|
|
return (pct / 100) * duration;
|
|
}
|
|
|
|
function nearestKeyframe(time: number): number {
|
|
if (keyframes.length === 0) return time;
|
|
let best = keyframes[0];
|
|
let dist = Math.abs(time - best);
|
|
for (const kf of keyframes) {
|
|
const d = Math.abs(time - kf);
|
|
if (d < dist) { best = kf; dist = d; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function snapTime(time: number): number {
|
|
if (app.trimMode === 'keyframe') return nearestKeyframe(time);
|
|
return time;
|
|
}
|
|
|
|
let scrubStartX = 0;
|
|
let scrubStartTime = 0;
|
|
|
|
function handlePointerDown(e: PointerEvent, mode: 'scrub' | 'in' | 'out') {
|
|
if (dragging) return;
|
|
dragging = mode;
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
|
|
if (mode === 'scrub') {
|
|
const pct = pctFromEvent(e);
|
|
const time = timeFromPct(pct);
|
|
onSeek(time);
|
|
scrubStartX = e.clientX;
|
|
scrubStartTime = time;
|
|
}
|
|
}
|
|
|
|
function handlePointerMove(e: PointerEvent) {
|
|
if (!trackRef || !innerRef || !dragging) return;
|
|
const rect = trackRef.getBoundingClientRect();
|
|
const innerWidth = innerRef.offsetWidth;
|
|
|
|
if (dragging === 'scrub') {
|
|
const vertDist = Math.abs(e.clientY - (rect.top + rect.height / 2));
|
|
const speed = vertDist < 30 ? 1 : vertDist < 80 ? 0.5 : vertDist < 150 ? 0.25 : 0.1;
|
|
const dx = e.clientX - scrubStartX;
|
|
const pxPerSecond = innerWidth / duration;
|
|
const timeDelta = (dx * speed) / pxPerSecond;
|
|
const newTime = Math.max(0, Math.min(duration, scrubStartTime + timeDelta));
|
|
scrubStartX = e.clientX;
|
|
scrubStartTime = newTime;
|
|
onSeek(newTime);
|
|
return;
|
|
}
|
|
|
|
const scrollLeft = trackRef.scrollLeft;
|
|
const x = e.clientX - rect.left + scrollLeft;
|
|
const pct = Math.max(0, Math.min(100, (x / innerWidth) * 100));
|
|
const time = timeFromPct(pct);
|
|
|
|
if (dragging === 'in') {
|
|
const snapped = snapTime(Math.min(time, outTime - 0.1));
|
|
app.setTrimRange(Math.max(0, snapped), outTime);
|
|
onSeek(Math.max(0, snapped));
|
|
} else if (dragging === 'out') {
|
|
const snapped = snapTime(Math.max(time, inTime + 0.1));
|
|
app.setTrimRange(inTime, Math.min(duration, snapped));
|
|
onSeek(Math.min(duration, snapped));
|
|
}
|
|
}
|
|
|
|
function handlePointerUp() {
|
|
dragging = null;
|
|
}
|
|
|
|
function handleHoverMove(e: MouseEvent) {
|
|
if (!trackRef || !innerRef || dragging) return;
|
|
const rect = trackRef.getBoundingClientRect();
|
|
const scrollLeft = trackRef.scrollLeft;
|
|
const innerWidth = innerRef.offsetWidth;
|
|
const x = e.clientX - rect.left + scrollLeft;
|
|
hoverTime = timeFromPct(Math.max(0, Math.min(100, (x / innerWidth) * 100)));
|
|
// viewport-relative tooltip position
|
|
hoverX = Math.max(62, Math.min(rect.width - 62, e.clientX - rect.left));
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
hoverX = null;
|
|
hoverTime = null;
|
|
}
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
if (e.deltaY < 0) zoomLevel = Math.min(8, zoomLevel * 1.15);
|
|
else zoomLevel = Math.max(1, zoomLevel / 1.15);
|
|
}
|
|
|
|
function handleModeChange(val: string) {
|
|
app.trimMode = val === 'Keyframe snap' ? 'keyframe' : 'smart';
|
|
}
|
|
|
|
function handleTrackKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
app.seek(app.currentTime - (e.shiftKey ? 5 : 1));
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
app.seek(app.currentTime + (e.shiftKey ? 5 : 1));
|
|
} else if (e.key === 'Home') {
|
|
e.preventDefault();
|
|
app.seek(0);
|
|
} else if (e.key === 'End') {
|
|
e.preventDefault();
|
|
app.seek(duration);
|
|
} else if (e.key === 'i' || e.key === 'I') {
|
|
e.preventDefault();
|
|
app.setTrimRange(currentTime, outTime);
|
|
onSeek(currentTime);
|
|
} else if (e.key === 'o' || e.key === 'O') {
|
|
e.preventDefault();
|
|
app.setTrimRange(inTime, currentTime);
|
|
onSeek(currentTime);
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-2 w-full">
|
|
<!-- main filmstrip area -->
|
|
<div class="tl-track-outer">
|
|
<div
|
|
bind:this={trackRef}
|
|
class="relative w-full cursor-crosshair select-none"
|
|
class:tl-animating={animating}
|
|
style="height: 72px; border-radius: var(--radius-md); overflow-x: auto; overflow-y: visible; background: var(--color-bg-elevated); border: 1px solid var(--color-border); scrollbar-width: none;"
|
|
role="slider"
|
|
aria-valuemin={0}
|
|
aria-valuemax={duration}
|
|
aria-valuenow={currentTime}
|
|
tabindex="0"
|
|
onpointerdown={(e) => handlePointerDown(e, 'scrub')}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
onmousemove={handleHoverMove}
|
|
onmouseleave={handleMouseLeave}
|
|
onwheel={handleWheel}
|
|
onkeydown={handleTrackKeydown}
|
|
>
|
|
<div bind:this={innerRef} class="tl-inner" style="width: calc(100% * {zoomLevel}); position: relative; height: 100%;">
|
|
<!-- thumbnail strip -->
|
|
{#if app.thumbnails.length > 0}
|
|
<div
|
|
class="absolute inset-0 flex"
|
|
style="opacity: 0.8"
|
|
>
|
|
{#each app.thumbnails as thumb, i}
|
|
<div
|
|
class="h-full flex-1"
|
|
style="background: url('{streamUrl(thumb)}') center/cover no-repeat"
|
|
></div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- keyframe color segments -->
|
|
{#each keyframes as kf, i}
|
|
{#if i > 0}
|
|
{@const prevPct = (keyframes[i - 1] / duration) * 100}
|
|
{@const kfPct = (kf / duration) * 100}
|
|
<div
|
|
class="absolute top-0 h-full"
|
|
style="
|
|
left: {prevPct}%;
|
|
width: {kfPct - prevPct}%;
|
|
background: {i % 2 === 0 ? 'rgba(5,150,105,0.05)' : 'rgba(37,99,235,0.05)'};
|
|
"
|
|
></div>
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- dark overlay for unselected regions -->
|
|
{#if app.trimRange}
|
|
<div
|
|
class="absolute top-0 h-full"
|
|
style="left: 0; width: {inPct}%; background: rgba(0,0,0,0.35)"
|
|
></div>
|
|
<div
|
|
class="absolute top-0 h-full"
|
|
style="left: {outPct}%; right: 0; background: rgba(0,0,0,0.35)"
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- selected region top/bottom border -->
|
|
{#if app.trimRange}
|
|
<div
|
|
class="absolute z-10 pointer-events-none"
|
|
style="
|
|
left: calc({inPct}%);
|
|
width: calc({outPct - inPct}%);
|
|
top: 0;
|
|
height: 100%;
|
|
border-top: 2px solid var(--color-accent-trim);
|
|
border-bottom: 2px solid var(--color-accent-trim);
|
|
"
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- IN handle -->
|
|
{#if app.trimRange}
|
|
<div
|
|
class="trim-handle trim-handle--in"
|
|
style="left: calc({inPct}% - {inPct > 0 ? 12 : 0}px);"
|
|
onpointerdown={(e) => handlePointerDown(e, 'in')}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
role="slider"
|
|
aria-label="Trim in point"
|
|
aria-valuenow={inTime}
|
|
tabindex="0"
|
|
>
|
|
<div class="trim-handle-inner">
|
|
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
|
|
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
|
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- OUT handle -->
|
|
<div
|
|
class="trim-handle trim-handle--out"
|
|
style="left: calc({outPct}% - {outPct >= 100 ? 12 : 0}px);"
|
|
onpointerdown={(e) => handlePointerDown(e, 'out')}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
role="slider"
|
|
aria-label="Trim out point"
|
|
aria-valuenow={outTime}
|
|
tabindex="0"
|
|
>
|
|
<div class="trim-handle-inner">
|
|
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
|
|
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
|
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- playhead -->
|
|
<div
|
|
class="playhead-wrapper"
|
|
style="--ph-pct: {playheadPct}"
|
|
>
|
|
<div class="playhead-line"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- hover tooltip -->
|
|
{#if hoverX !== null && hoverTime !== null && !dragging}
|
|
<div
|
|
class="absolute z-40 pointer-events-none"
|
|
style="
|
|
bottom: calc(100% + 8px);
|
|
left: {hoverX}px;
|
|
transform: translateX(-50%);
|
|
"
|
|
>
|
|
<div
|
|
class="flex flex-col items-center gap-1"
|
|
style="
|
|
background: var(--color-bg-elevated);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 4px 6px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
"
|
|
>
|
|
<!-- mini thumb -->
|
|
{#if duration > 0 && app.thumbnails.length > 0}
|
|
{@const thumbIdx = Math.min(Math.floor((hoverTime / duration) * app.thumbnails.length), app.thumbnails.length - 1)}
|
|
<div
|
|
class="rounded"
|
|
style="width: 120px; height: 68px; background: url('{streamUrl(app.thumbnails[thumbIdx])}') center/cover no-repeat"
|
|
></div>
|
|
{:else}
|
|
<div
|
|
class="rounded"
|
|
style="width: 120px; height: 68px; background: var(--color-bg-surface)"
|
|
></div>
|
|
{/if}
|
|
<span class="font-mono text-xs" style="color: var(--color-text-secondary)">
|
|
{formatTimecode(hoverTime)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- below timeline: controls row -->
|
|
<div class="tl-controls">
|
|
<!-- left: cut mode toggle -->
|
|
<div class="tl-mode">
|
|
<button
|
|
type="button"
|
|
class="tl-mode-btn"
|
|
class:tl-mode-btn--active={app.trimMode === 'keyframe'}
|
|
onclick={() => handleModeChange('Keyframe snap')}
|
|
>
|
|
<i class="ti ti-scissors" style="font-size: 13px"></i>
|
|
Keyframe
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="tl-mode-btn"
|
|
class:tl-mode-btn--active={app.trimMode === 'smart'}
|
|
onclick={() => handleModeChange('Smart cut')}
|
|
>
|
|
<i class="ti ti-cut" style="font-size: 13px"></i>
|
|
Smart
|
|
</button>
|
|
</div>
|
|
|
|
<!-- right: timecodes -->
|
|
<div class="tl-times">
|
|
<div class="tl-time-field">
|
|
<button
|
|
type="button"
|
|
class="tl-time-label tl-time-label--btn"
|
|
title="Set in point to current position"
|
|
onclick={() => { animating = true; app.setTrimRange(currentTime, outTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
|
|
>IN</button>
|
|
<input
|
|
type="text"
|
|
class="tl-time-input"
|
|
bind:value={inInput}
|
|
onblur={commitIn}
|
|
onkeydown={handleInKey}
|
|
onfocus={(e) => (e.target as HTMLInputElement).select()}
|
|
/>
|
|
<span class="tl-kbd-hint"><kbd>I</kbd></span>
|
|
</div>
|
|
<div class="tl-time-field">
|
|
<button
|
|
type="button"
|
|
class="tl-time-label tl-time-label--btn"
|
|
title="Set out point to current position"
|
|
onclick={() => { animating = true; app.setTrimRange(inTime, currentTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
|
|
>OUT</button>
|
|
<input
|
|
type="text"
|
|
class="tl-time-input"
|
|
bind:value={outInput}
|
|
onblur={commitOut}
|
|
onkeydown={handleOutKey}
|
|
onfocus={(e) => (e.target as HTMLInputElement).select()}
|
|
/>
|
|
<span class="tl-kbd-hint"><kbd>O</kbd></span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="tl-duration"
|
|
title="Reset trim to full video"
|
|
onclick={() => { animating = true; app.clearTrimRange(); setTimeout(() => { animating = false; }, 500); }}
|
|
>
|
|
<i class="ti ti-clock" style="font-size: 12px"></i>
|
|
{formatTimecodeShort(selectedDuration)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.tl-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.tl-mode {
|
|
display: flex;
|
|
gap: 2px;
|
|
background: var(--color-bg-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 2px;
|
|
}
|
|
.tl-mode-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 5px 10px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
background: none;
|
|
color: var(--color-text-disabled);
|
|
font-family: var(--font-body);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: color var(--transition-fast), background var(--transition-fast);
|
|
white-space: nowrap;
|
|
}
|
|
.tl-mode-btn:hover {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.tl-mode-btn--active {
|
|
background: var(--color-accent-trim);
|
|
color: white;
|
|
}
|
|
.tl-mode-btn--active:hover {
|
|
color: white;
|
|
}
|
|
|
|
.tl-times {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tl-time-field {
|
|
display: flex;
|
|
align-items: stretch;
|
|
background: var(--color-bg-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
.tl-time-field:focus-within {
|
|
border-color: var(--color-accent-trim);
|
|
}
|
|
|
|
.tl-time-label {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 8px;
|
|
font-family: var(--font-body);
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-text-disabled);
|
|
background: var(--color-bg-elevated);
|
|
border: none;
|
|
border-right: 1px solid var(--color-border);
|
|
user-select: none;
|
|
}
|
|
.tl-time-label--btn {
|
|
cursor: pointer;
|
|
transition: color var(--transition-fast), background var(--transition-fast);
|
|
}
|
|
.tl-time-label--btn:hover {
|
|
color: var(--color-accent-trim);
|
|
background: var(--color-bg-surface);
|
|
}
|
|
|
|
.tl-time-input {
|
|
width: 90px;
|
|
padding: 5px 8px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--color-text-secondary);
|
|
background: none;
|
|
border: none;
|
|
outline: none;
|
|
}
|
|
.tl-time-input:focus {
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.tl-duration {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 5px 10px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
background: var(--color-bg-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
transition: border-color var(--transition-fast), color var(--transition-fast);
|
|
}
|
|
.tl-duration:hover {
|
|
border-color: var(--color-text-secondary);
|
|
color: var(--color-accent-compress);
|
|
}
|
|
|
|
/* when animating is active (button clicks), transition all positioned elements */
|
|
.tl-animating .trim-handle,
|
|
.tl-animating .trim-handle ~ div,
|
|
:global(.tl-animating) :is([style*="left:"], [style*="width:"]) {
|
|
transition: left 500ms ease-in-out, width 500ms ease-in-out, right 500ms ease-in-out;
|
|
}
|
|
|
|
.tl-track-outer {
|
|
position: relative;
|
|
height: 72px;
|
|
}
|
|
|
|
.tl-inner {
|
|
min-width: 100%;
|
|
}
|
|
|
|
.playhead-wrapper {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 30;
|
|
will-change: transform;
|
|
transform: translateX(calc(var(--ph-pct) * 1% - 1px));
|
|
}
|
|
.playhead-line {
|
|
width: 2px;
|
|
height: 100%;
|
|
background: var(--color-accent-compress);
|
|
box-shadow: 0 0 6px rgba(5,150,105,0.7), 0 0 12px rgba(5,150,105,0.4);
|
|
}
|
|
|
|
.tl-kbd-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 6px;
|
|
font-family: var(--font-mono);
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
color: var(--color-text-disabled);
|
|
user-select: none;
|
|
}
|
|
.tl-kbd-hint kbd {
|
|
background: var(--color-bg-elevated);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
padding: 1px 4px;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.trim-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
width: 14px;
|
|
z-index: 20;
|
|
cursor: ew-resize;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-accent-trim);
|
|
}
|
|
.trim-handle--in {
|
|
border-radius: 4px 0 0 4px;
|
|
}
|
|
.trim-handle--out {
|
|
border-radius: 0 4px 4px 0;
|
|
}
|
|
.trim-handle-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: transform var(--transition-fast), opacity var(--transition-fast);
|
|
opacity: 0.7;
|
|
}
|
|
.trim-handle:hover .trim-handle-inner {
|
|
opacity: 1;
|
|
transform: scaleX(1.4);
|
|
}
|
|
</style>
|