diff --git a/src/App.css b/src/App.css index dda5891..69c3b59 100644 --- a/src/App.css +++ b/src/App.css @@ -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; @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index 76d1fe8..7fe8b3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -247,11 +247,9 @@ function App() { const [drag, setDrag] = createSignal(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(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(null); @@ -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" @@ -1556,7 +1551,6 @@ function App() { setTrimState(next); }; const onTrimUp = async () => { - setSnapGuide(null); if (!trimDrag) return; trimDrag = null; const t = trim(); @@ -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); @@ -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); @@ -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); @@ -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<{ @@ -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); @@ -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"; @@ -2460,21 +2374,29 @@ function App() { {(() => { - 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 ( queueMicrotask(() => { el.focus(); el.select(); })} onInput={(e) => editTextLive(e.currentTarget.value)} @@ -2849,6 +2771,21 @@ function App() { + + {/* Tool-context panel: holds the inspector column while a drawing tool is armed + (nothing selected yet) so placing an element never reflows the canvas. */} + + +
@@ -3170,10 +3107,6 @@ function App() {
- - -
-
@@ -3207,15 +3140,15 @@ function App() {

Record. Auto-zoom. Ship.

- 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.

1
Record - Pick an area and hit record — your clicks drive cinematic zooms. + Pick an area and hit record. Your clicks drive the zoom.
@@ -3234,11 +3167,11 @@ function App() {
-