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
35 changes: 23 additions & 12 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1547,17 +1547,6 @@ kbd {
background: var(--accent);
}

/* Snap guide — a dashed accent line that flashes where a dragged band catches. */
.tl-snapguide {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 1px dashed var(--accent);
opacity: 0.85;
pointer-events: none;
z-index: 5;
}
.tl-playhead {
position: absolute;
top: 0;
Expand Down Expand Up @@ -1766,11 +1755,33 @@ kbd {
}
.welcome-actions {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-10);
}
/* Secondary "Skip" — quiet, so it doesn't compete with the CTA. */
.welcome-skip {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.welcome-skip:hover {
background: var(--panel-2);
color: var(--text);
}
/* Primary CTA — filled red, but the same height as Skip (no oversized type). */
.welcome-cta {
margin-top: 0;
background: var(--record);
border-color: var(--record);
color: #fff;
font-weight: 600;
}
.welcome-cta:hover {
background: var(--record);
filter: brightness(1.08);
}
.welcome-cta .dot {
background: #fff;
}
/* Coachmark bubble pointing up at the Record button. */
.coachmark {
Expand Down
163 changes: 48 additions & 115 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,9 @@ function App() {
const [drag, setDrag] = createSignal<Drag>(null);
const [stage, setStage] = createSignal({ w: 1, h: 1 });
const [frameAspect, setFrameAspect] = createSignal(16 / 9);
// Pixel width of the timeline track surface — drives adaptive ruler ticks and the
// pixel-constant snap threshold (kept in sync via a ResizeObserver in onMount).
// Pixel width of the timeline track surface — drives the adaptive ruler ticks
// (kept in sync via a ResizeObserver in onMount).
const [tlWidth, setTlWidth] = createSignal(800);
// While a timeline band is dragged, the time it snapped to (for the guide line), or null.
const [snapGuide, setSnapGuide] = createSignal<number | null>(null);
// While an annotation is dragged on the canvas, the normalized x/y of an active
// center/edge snap guide (or null). Drawn as crosshair lines on the overlay.
const [snapX, setSnapX] = createSignal<number | null>(null);
Expand Down Expand Up @@ -1542,10 +1540,7 @@ function App() {
};
const onTrimMove = (e: PointerEvent) => {
if (!trimDrag || !tlEl) return;
const raw = tlTime(e);
const hit = nearestTarget(raw, snapTargets({ kind: "trim" }));
setSnapGuide(hit);
const t = hit ?? raw;
const t = tlTime(e);
const cur = trim() ?? { start: 0, end: duration() };
const next =
trimDrag === "start"
Expand All @@ -1556,7 +1551,6 @@ function App() {
setTrimState(next);
};
const onTrimUp = async () => {
setSnapGuide(null);
if (!trimDrag) return;
trimDrag = null;
const t = trim();
Expand Down Expand Up @@ -1612,12 +1606,10 @@ function App() {
} else {
end = Math.max(Math.min(duration(), end + dt), start + 0.2);
}
({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "zoom", idx: d.idx }), 0.2));
setZoomDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 });
};
const onZoomUp = async () => {
const d = zoomDrag();
setSnapGuide(null);
if (!d) return;
setZoomDrag(null);
setSelected(null);
Expand Down Expand Up @@ -1670,12 +1662,10 @@ function App() {
} else {
end = Math.max(Math.min(duration(), end + dt), start + 0.2);
}
({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "speed", idx: d.idx }), 0.2));
setSpeedDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 });
};
const onSpeedUp = async () => {
const d = speedDrag();
setSnapGuide(null);
if (!d) return;
setSpeedDrag(null);
setSelected(null);
Expand Down Expand Up @@ -1729,12 +1719,10 @@ function App() {
} else {
end = Math.max(Math.min(duration(), end + dt), start + 0.1);
}
({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "cut", idx: d.idx }), 0.1));
setCutDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 });
};
const onCutUp = async () => {
const d = cutDrag();
setSnapGuide(null);
if (!d) return;
setCutDrag(null);
setSelected(null);
Expand Down Expand Up @@ -1784,82 +1772,6 @@ function App() {
return out;
};

// ── timeline snapping (pixel-constant, zoom-independent) ────────────────────────
// A dragged band's edges snap to the playhead, the clip bounds, major ruler ticks, and
// every other band's edges — within ~8px regardless of clip length — and a guide line
// flashes at the catch point.
const snapThresholdT = () => 8 / Math.max(pxPerSec(), 1e-6);
const snapTargets = (exclude?: {
kind: "zoom" | "speed" | "cut" | "ann" | "trim";
idx?: number;
id?: number;
}): number[] => {
const ts: number[] = [0, duration(), playhead()];
for (const m of tickMarks()) if (m.major) ts.push(m.t);
zooms().forEach((z, i) => {
if (!(exclude?.kind === "zoom" && exclude.idx === i)) ts.push(z.start, z.end);
});
speed().forEach((r, i) => {
if (!(exclude?.kind === "speed" && exclude.idx === i)) ts.push(r.start, r.end);
});
cuts().forEach((c, i) => {
if (!(exclude?.kind === "cut" && exclude.idx === i)) ts.push(c.start, c.end);
});
annBars().forEach((b) => {
if (!(exclude?.kind === "ann" && exclude.id === b.id)) ts.push(b.start, b.end);
});
return ts;
};
const nearestTarget = (t: number, targets: number[]): number | null => {
let hit: number | null = null;
let bestD = snapThresholdT();
for (const tg of targets) {
const dd = Math.abs(t - tg);
if (dd <= bestD) {
bestD = dd;
hit = tg;
}
}
return hit;
};
// Snap a dragged band for the given mode; updates the guide and returns the new band.
const snapBand = (
start: number,
end: number,
mode: "move" | "l" | "r",
targets: number[],
minGap: number,
): { start: number; end: number } => {
const d = duration();
if (mode === "l") {
const hit = nearestTarget(start, targets);
const v = hit === null ? start : Math.min(Math.max(0, hit), end - minGap);
setSnapGuide(hit !== null && v === hit ? hit : null);
return { start: v, end };
}
if (mode === "r") {
const hit = nearestTarget(end, targets);
const v = hit === null ? end : Math.max(Math.min(d, hit), start + minGap);
setSnapGuide(hit !== null && v === hit ? hit : null);
return { start, end: v };
}
// move: snap whichever edge lands closest to a target, shifting the whole band.
const hs = nearestTarget(start, targets);
const he = nearestTarget(end, targets);
const ds = hs !== null ? Math.abs(start - hs) : Infinity;
const de = he !== null ? Math.abs(end - he) : Infinity;
const len = end - start;
if (ds <= de && hs !== null && hs >= 0 && hs + len <= d) {
setSnapGuide(hs);
return { start: hs, end: hs + len };
}
if (he !== null && he - len >= 0 && he <= d) {
setSnapGuide(he);
return { start: he - len, end: he };
}
setSnapGuide(null);
return { start, end };
};
// Annotation bar dragging: grab the middle to move it in time, the edges to resize
// how long it stays on screen.
const [annDrag, setAnnDrag] = createSignal<{
Expand Down Expand Up @@ -1904,12 +1816,10 @@ function App() {
} else {
end = Math.max(Math.min(duration(), end + dt), start + 0.2);
}
({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "ann", id: d.id }), 0.2));
setAnnDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 });
};
const onAnnUp = async () => {
const d = annDrag();
setSnapGuide(null);
if (!d) return;
setAnnDrag(null);
setSelZoom(null);
Expand Down Expand Up @@ -1977,8 +1887,12 @@ function App() {
/* storage unavailable */
}
};
const inspectorOpen = () =>
const somethingSelected = () =>
!!selected() || selZoom() !== null || selSpeed() !== null || selCut() !== null;
// A drawing tool is armed (not Select). We reserve the inspector column for it so the
// canvas doesn't reflow the moment you place an element (which would jump the editor box).
const drawingToolActive = () => hasClip() && tool() !== "select";
const inspectorOpen = () => somethingSelected() || drawingToolActive();

const safeName = () =>
projectName().replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "vuoom";
Expand Down Expand Up @@ -2460,21 +2374,29 @@ function App() {

<Show when={editingTextAnn()}>
{(() => {
const ta = editingTextAnn()!;
const p = px({ x: v2(ta.pos).x, y: v2(ta.pos).y });
const fs = ta.font_size * stage().h;
const id = editingText()!;
// Reactive accessors so the editor box tracks the label as the canvas
// resizes (e.g. when the inspector opens). The value stays uncontrolled
// (seeded once) so typing never resets the caret.
const live = () => anns().texts.find((t) => t.id === id);
const initial = live()?.text ?? "";
const p = () => {
const t = live();
return t ? px({ x: v2(t.pos).x, y: v2(t.pos).y }) : { x: 0, y: 0 };
};
const fs = () => (live()?.font_size ?? 0.05) * stage().h;
return (
<input
class="text-edit"
style={{
left: `${p.x}px`,
top: `${p.y}px`,
"font-size": `${fs}px`,
"font-family": fontCss(ta.font),
"font-weight": ta.bold ? "700" : "400",
"font-style": ta.italic ? "italic" : "normal",
left: `${p().x}px`,
top: `${p().y}px`,
"font-size": `${fs()}px`,
"font-family": fontCss(live()?.font ?? ""),
"font-weight": live()?.bold ? "700" : "400",
"font-style": live()?.italic ? "italic" : "normal",
}}
value={ta.text}
value={initial}
spellcheck={false}
ref={(el) => queueMicrotask(() => { el.focus(); el.select(); })}
onInput={(e) => editTextLive(e.currentTarget.value)}
Expand Down Expand Up @@ -2849,6 +2771,21 @@ function App() {
</button>
</InspectorPanel>
</Show>

{/* Tool-context panel: holds the inspector column while a drawing tool is armed
(nothing selected yet) so placing an element never reflows the canvas. */}
<Show when={drawingToolActive() && !somethingSelected()}>
<aside class="properties">
<div class="inspector-head">
<h2>{TOOLS.find((t) => t.id === tool())?.label}</h2>
</div>
<p class="muted small">{TOOLS.find((t) => t.id === tool())?.hint}</p>
<p class="muted small">
Press <kbd>V</kbd> for Select, or pick another tool on the left. Its options appear
here once you place an element.
</p>
</aside>
</Show>
</div>

<footer class="timeline">
Expand Down Expand Up @@ -3170,10 +3107,6 @@ function App() {
<div class="tl-playhead" style={{ left: `${pct(playhead())}%` }}>
<i />
</div>

<Show when={snapGuide() !== null}>
<div class="tl-snapguide" style={{ left: `${pct(snapGuide()!)}%` }} />
</Show>
</Show>
</div>
</footer>
Expand Down Expand Up @@ -3207,15 +3140,15 @@ function App() {
<LogoWordmark />
<h2 class="welcome-title">Record. Auto-zoom. Ship.</h2>
<p class="welcome-sub">
Vuoom records your screen, zooms in where you click, and exports a crisp GIF or
MP4 — ready for your README, Slack, or socials.
Record your screen, auto-zoom where you click, and export a crisp GIF or MP4 for
your README, Slack, or socials.
</p>
<div class="welcome-steps">
<div class="welcome-step">
<span class="welcome-num">1</span>
<div>
<strong>Record</strong>
<small>Pick an area and hit record — your clicks drive cinematic zooms.</small>
<small>Pick an area and hit record. Your clicks drive the zoom.</small>
</div>
</div>
<div class="welcome-step">
Expand All @@ -3234,11 +3167,11 @@ function App() {
</div>
</div>
<div class="welcome-actions">
<button class="btn" onClick={() => dismissWelcome(true)}>
Maybe later
<button class="btn welcome-skip" onClick={() => dismissWelcome(true)}>
Skip
</button>
<button
class="btn record cta welcome-cta"
class="btn record welcome-cta"
onClick={() => {
dismissWelcome(false);
void startRecord();
Expand All @@ -3256,7 +3189,7 @@ function App() {
<div class="coachmark" style={{ left: `${coachPos().x}px`, top: `${coachPos().y + 10}px` }}>
<span class="coach-arrow" />
<p>
Start here — or press <kbd>Ctrl+Shift+R</kbd> any time.
Click to start, or press <kbd>Ctrl+Shift+R</kbd> any time.
</p>
<button class="btn ghost coach-dismiss" onClick={() => setCoachRecord(false)}>
Got it
Expand Down
Loading