From e24c2e0e2d62e43767e5db19737867cf8594a95f Mon Sep 17 00:00:00 2001 From: Tomasz Plonka <4361591+PlkMarudny@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:05:04 +0300 Subject: [PATCH] feat: Ctrl+Click on a slider in the inspector resets its value to default --- CHANGELOG.md | 3 +++ README.md | 2 +- app.js | 49 ++++++++++++++++++++++++++++++++------------- index.html | 2 +- test/assets.test.js | 9 +++++++++ 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8beace..1138d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Ctrl/Cmd-click an inspector **slider** to reset that property to its default + and clear its keyframes (same as Ctrl/Cmd-clicking the label — scale → 1, + opacity → 1, brightness → 100, …). - **Import from URL** — `POST /api/import-url` downloads an HTTPS video, audio or image into `./media/` and returns a same-origin `/media/…` src. The editor **+ URL** button and `fablecut_import_media` (now accepts `https://` diff --git a/README.md b/README.md index ff77105..5773077 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ same time. - **Zoom to selection** (⇧Z) frames all selected clips, not just one - **IN/OUT work area** — set markers with i and o (⇧I / ⇧O to clear). Enabling **Limit** constrains playback to the marked range and maps Home / End to the IN and OUT positions rather than the full timeline. t splits clips at the markers; ⇧t trims clips to the work (between marker in and marker out) area. - **Find & close gaps** — a gap is a stretch where every enabled track is empty (black frames). g jumps the playhead to the next shared gap (wraps; respects IN/OUT when both are set). ⇧G closes the gap under the playhead by pulling later clips left on all enabled tracks. -- **Reset a property** — Ctrl/Cmd+click an inspector **label** restores that effect/prop to its default *and* clears every keyframe on the channel (paired fields like Crop L/R reset together; transition labels clear the in/out transition). Shift+click the same label is playhead-local: if you are parked on a keyframe it removes **that** keyframe only; otherwise it sets the value at the playhead to the default (auto-keys if the channel is already animated). +- **Reset a property** — Ctrl/Cmd+click an inspector **label** or **slider** restores that effect/prop to its default *and* clears every keyframe on the channel (scale → 1, opacity → 1, paired fields like Crop L/R reset together; transition labels clear the in/out transition). Shift+click a label is playhead-local: if you are parked on a keyframe it removes **that** keyframe only; otherwise it sets the value at the playhead to the default (auto-keys if the channel is already animated). - **Replace media** — the inspector's **Source** button (any video/audio/image/svg clip) swaps the underlying file while keeping position, trim, keyframes, transitions and every effect. Pick another item already in the bin or diff --git a/app.js b/app.js index 23237d1..801c541 100644 --- a/app.js +++ b/app.js @@ -715,6 +715,19 @@ function resetPropAtPlayhead(c, k) { if (k === "font") ensureFont(String(def)); return true; } +function applyInspectorReset(keys, channelWide) { + const c = getClip(state.selId); + if (!c || !keys.length) return; + pushUndo(); + let refused = false; + for (const k of keys) { + if (channelWide) resetPropChannel(c, k); + else refused = !resetPropAtPlayhead(c, k) || refused; + } + if (refused) toast("Move the playhead over the clip to edit its keyframes"); + scheduleSave(); + renderInspector(); +} /* ◆ : add a keyframe at the playhead, or remove the one already there. Refused (false) when the playhead is off the clip — there is no "at the playhead" then, and clamping would plant a keyframe on the clip's edge. */ @@ -3609,7 +3622,7 @@ function renderInspector(lite) { const slider = (k, min, max, step, val, unit = "") => { const shown = fmtInspNum(val, step); return row(k[0].toUpperCase() + k.slice(1), - ` + ` ${shown}${unit}`, k); }; let html = (state.selIds.size > 1 @@ -3706,7 +3719,7 @@ function renderInspector(lite) { html += `

Text

${row("Content", ``, "", "text")} ${row(hasTextBox(p) && p.boxFit ? "Max size" : "Font size", - ` + ` ${fmtInspNum(p.fontSize, 1)}px`, "fontSize")} ${row("Box W/H", ` @@ -3761,22 +3774,14 @@ function renderInspector(lite) { const local = e.shiftKey && !all; if (!all && !local) return; e.preventDefault(); - const keys = lab.dataset.reset.split(",").map((s) => s.trim()).filter(Boolean); - if (!keys.length) return; - pushUndo(); - let refused = false; - for (const k of keys) { - if (all) resetPropChannel(c, k); // channel-wide: playhead-independent - else refused = !resetPropAtPlayhead(c, k) || refused; - } - if (refused) toast("Move the playhead over the clip to edit its keyframes"); - scheduleSave(); - renderInspector(); + applyInspectorReset(lab.dataset.reset.split(",").map((s) => s.trim()).filter(Boolean), all); }); }); els.inspector.querySelectorAll("[data-k]").forEach((input) => { const k = input.dataset.k; - input.addEventListener("input", () => { + input.addEventListener("input", (e) => { + if (!input.isConnected) return; + if (input.type === "range" && (e.ctrlKey || e.metaKey)) return; let v = input.type === "checkbox" ? input.checked : input.type === "range" || input.type === "number" ? parseFloat(input.value) : input.value; @@ -3885,6 +3890,22 @@ function renderInspector(lite) { syncInspectorOffClip(c); renderKfGraphsPanel(); } +/* One listener for every slider: capture so preventDefault runs before the + range jumps the thumb to the click. */ +els.inspector.addEventListener("pointerdown", (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + const input = e.target.closest?.("input[type=range][data-k]"); + if (!input || !els.inspector.contains(input)) return; + e.preventDefault(); + const k = input.dataset.k; + if (!k || !Object.hasOwn(DEFAULT_PROPS, k)) return; + applyInspectorReset([k], true); +}, true); +els.inspector.addEventListener("contextmenu", (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + if (!e.target.closest?.("input[type=range][data-k]")) return; + e.preventDefault(); +}); /* Off the clip, keyframed fields show their edge value but must not be editable — a write would land clamped on the clip's edge. Disables those diff --git a/index.html b/index.html index ba3ca98..57fe549 100644 --- a/index.html +++ b/index.html @@ -286,7 +286,7 @@

Keyboard shortcuts

Ctrl+A / EscSelect all / deselect Step 1 frame (⇧ = 1 second) Ctrl/Cmd+ / Ctrl/Cmd+Go to previous / next keyframe (inspector follows) - Ctrl/Cmd+click inspector labelReset property + clear all its keyframes + Ctrl/Cmd+click inspector label or sliderReset property + clear all its keyframes +click inspector labelReset value at playhead, or remove that keyframe [ / ]Trim selected in / out to playhead Home / EndJump to start / end diff --git a/test/assets.test.js b/test/assets.test.js index 2b84bf9..b747cda 100644 --- a/test/assets.test.js +++ b/test/assets.test.js @@ -96,3 +96,12 @@ test("package.json declares no runtime dependencies", () => { "FableCut must stay zero-runtime-dependency"); assert.ok(pkg.scripts?.test, "package.json needs a test script so CI can run the suite"); }); + +test("inspector sliders advertise and handle Ctrl/Cmd-click reset", () => { + const app = fs.readFileSync(path.join(ROOT, "app.js"), "utf8"); + assert.match(app, /els\.inspector\.addEventListener\("pointerdown"/, + "slider reset is delegated on the inspector, not bound per range"); + assert.match(app, /Ctrl\/Cmd-click: reset to default/); + const html = fs.readFileSync(path.join(ROOT, "index.html"), "utf8"); + assert.match(html, /inspector label or slider/); +});