소스 검색

Add Pixel Studio gradient tool

Toby Chui 6 일 전
부모
커밋
64606f9737

+ 102 - 0
src/web/Pixel Studio/css/style.css

@@ -581,6 +581,108 @@ input, select, button, textarea {
 }
 .cp-hexrow { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
 .cp-hexrow label { font-size: 11px; color: var(--text-dim); width: 30px; }
+
+/* ---- gradient tool ---- */
+.grad-opt-preview {
+    width: 90px;
+    height: 18px;
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    cursor: pointer;
+    vertical-align: middle;
+}
+.grad-opt-preview:hover { outline: 1px solid var(--accent); }
+
+.grad-editor { padding: 12px; user-select: none; width: 320px; }
+.grad-presets {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 5px;
+    margin-bottom: 12px;
+}
+.grad-thumb {
+    width: 52px;
+    height: 20px;
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    cursor: pointer;
+}
+.grad-thumb:hover { outline: 1px solid #fff; }
+.grad-thumb.active { outline: 2px solid var(--accent); }
+
+.grad-bar-wrap { position: relative; margin: 4px 0 22px 0; }
+.grad-bar {
+    width: 100%;
+    height: 26px;
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    cursor: copy;
+    display: block;
+}
+.grad-track { position: absolute; left: 0; right: 0; top: 28px; height: 14px; }
+.grad-stop {
+    position: absolute;
+    top: 0;
+    transform: translateX(-50%);
+    width: 12px;
+    height: 14px;
+    cursor: ew-resize;
+}
+.grad-stop::before {
+    content: "";
+    position: absolute;
+    top: -5px; left: 50%;
+    transform: translateX(-50%);
+    border-left: 5px solid transparent;
+    border-right: 5px solid transparent;
+    border-bottom: 5px solid var(--text-dim);
+}
+.grad-stop.selected::before { border-bottom-color: #fff; }
+.grad-stop i {
+    display: block;
+    width: 12px;
+    height: 12px;
+    border: 1px solid #000;
+    outline: 1px solid #888;
+    border-radius: 2px;
+}
+.grad-stop.selected i { outline: 1px solid var(--accent); }
+
+.grad-stop-controls {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 8px;
+    min-height: 26px;
+}
+.grad-stop-well {
+    width: 30px;
+    height: 22px;
+    border: 1px solid var(--border-light);
+    border-radius: 3px;
+    cursor: pointer;
+}
+.grad-pos { display: flex; align-items: center; gap: 6px; }
+.grad-pos label { font-size: 11px; color: var(--text-dim); }
+.grad-pos input {
+    width: 56px;
+    background: var(--bg-input);
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    padding: 3px 6px;
+    color: var(--text);
+}
+.grad-del {
+    margin-left: auto;
+    background: var(--bg-input);
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    padding: 4px 12px;
+    cursor: pointer;
+    color: var(--text);
+}
+.grad-del:hover:not(:disabled) { background: #7a2a2a; border-color: #7a2a2a; color: #fff; }
+.grad-del:disabled { opacity: 0.4; cursor: default; }
 .cp-hex {
     flex: 1 1 auto;
     background: var(--bg-input);

BIN
src/web/Pixel Studio/img/file_icon.png


BIN
src/web/Pixel Studio/img/file_icon.psd


+ 1 - 0
src/web/Pixel Studio/index.html

@@ -90,6 +90,7 @@
     <script src="js/selection.js"></script>
     <script src="js/guides.js"></script>
     <script src="js/tools.js"></script>
+    <script src="js/gradient.js"></script>
     <script src="js/text.js"></script>
     <script src="js/filters.js"></script>
     <script src="js/fileio.js"></script>

+ 13 - 6
src/web/Pixel Studio/js/colorpicker.js

@@ -63,11 +63,15 @@ PS.cmykToRgb = function (c, m, y, k) {
 
 PS._colorPicker = null;
 
-PS.openColorPicker = function (target) {
-    target = (target === "bg") ? "bg" : "fg";
+// target: "fg" | "bg" | (any other value = custom, then opts = {initial, title,
+// onChange(hex), onClose}). Custom mode lets callers (e.g. the gradient editor)
+// reuse the wheel to pick an arbitrary color with alpha.
+PS.openColorPicker = function (target, opts) {
+    opts = opts || {};
+    var custom = (target !== "fg" && target !== "bg");
     if (PS._colorPicker) { PS._colorPicker.close(); }
 
-    var startHex = (target === "fg") ? PS.fg : PS.bg;
+    var startHex = custom ? (opts.initial || "#000000") : ((target === "fg") ? PS.fg : PS.bg);
     var rgb0 = PS.hexToRgb(startHex) || { r: 0, g: 0, b: 0, a: 255 };
     var hsv0 = PS.rgbToHsv(rgb0.r, rgb0.g, rgb0.b);
     var state = { h: hsv0.h, s: hsv0.s, v: hsv0.v, a: (rgb0.a === undefined ? 255 : rgb0.a) };
@@ -79,7 +83,7 @@ PS.openColorPicker = function (target) {
     var wheelCacheV = null, wheelCacheImg = null;
 
     var panel = PS.floatingPanel({
-        title: "Color Picker — " + (target === "fg" ? "Foreground" : "Background"),
+        title: custom ? (opts.title || "Select Color") : ("Color Picker — " + (target === "fg" ? "Foreground" : "Background")),
         x: Math.round(window.innerWidth / 2 - 150),
         y: 90,
         build: function (body) {
@@ -173,7 +177,8 @@ PS.openColorPicker = function (target) {
         buttons: [{ label: "Done", primary: true }],
         onClose: function () {
             PS._colorPicker = null;
-            if (target === "fg") { PS.pushRecentColor(PS.fg); }
+            if (custom) { if (opts.onClose) { opts.onClose(); } }
+            else if (target === "fg") { PS.pushRecentColor(PS.fg); }
         }
     });
     PS._colorPicker = panel;
@@ -318,7 +323,9 @@ PS.openColorPicker = function (target) {
     function apply() {
         var rgb = PS.hsvToRgb(state.h, state.s, state.v);
         var hex = PS.rgbToHex(rgb.r, rgb.g, rgb.b, state.a);
-        if (target === "fg") { PS.setFg(hex, true); } else { PS.setBg(hex); }
+        if (custom) { if (opts.onChange) { opts.onChange(hex); } }
+        else if (target === "fg") { PS.setFg(hex, true); }
+        else { PS.setBg(hex); }
     }
 
     function refresh() {

+ 469 - 0
src/web/Pixel Studio/js/gradient.js

@@ -0,0 +1,469 @@
+/*
+    Pixel Studio - Gradient tool
+    Drag a line on the active layer to fill it with a gradient. Supports a
+    single color fading to transparent, two-color transitions, and multi-stop
+    custom gradients. Styles: linear, radial, reflected. Foreground/background
+    presets resolve live; custom gradients keep their own stops. Respects the
+    active selection mask and the tool opacity.
+*/
+"use strict";
+
+/* ---------- presets ---------- */
+
+PS.gradientPresets = [
+    { id: "fg-bg", label: "Foreground to Background", dyn: true },
+    { id: "fg-transparent", label: "Foreground to Transparent", dyn: true },
+    { id: "fg-white", label: "Foreground to White", dyn: true },
+    { id: "fg-black", label: "Foreground to Black", dyn: true },
+    { id: "black-white", label: "Black, White", stops: [{ pos: 0, color: "#000000" }, { pos: 1, color: "#ffffff" }] },
+    { id: "white-transparent", label: "White to Transparent", stops: [{ pos: 0, color: "#ffffff" }, { pos: 1, color: "#ffffff00" }] },
+    { id: "black-transparent", label: "Black to Transparent", stops: [{ pos: 0, color: "#000000" }, { pos: 1, color: "#00000000" }] },
+    { id: "sunrise", label: "Red, Yellow", stops: [{ pos: 0, color: "#ff3b30" }, { pos: 1, color: "#ffe000" }] },
+    { id: "ocean", label: "Blue, Cyan", stops: [{ pos: 0, color: "#1d4ed8" }, { pos: 1, color: "#22d3ee" }] },
+    { id: "violet-orange", label: "Violet, Orange", stops: [{ pos: 0, color: "#7c3aed" }, { pos: 1, color: "#fb923c" }] },
+    {
+        id: "spectrum", label: "Spectrum", stops: [
+            { pos: 0.00, color: "#ff0000" }, { pos: 0.17, color: "#ff9900" },
+            { pos: 0.34, color: "#ffff00" }, { pos: 0.50, color: "#33cc33" },
+            { pos: 0.67, color: "#00ccff" }, { pos: 0.84, color: "#3333ff" },
+            { pos: 1.00, color: "#cc33ff" }
+        ]
+    },
+    {
+        id: "rainbow-transparent", label: "Transparent Rainbow", stops: [
+            { pos: 0, color: "#ff000000" }, { pos: 0.25, color: "#ffff00ff" },
+            { pos: 0.5, color: "#00ff00ff" }, { pos: 0.75, color: "#00ffffff" },
+            { pos: 1, color: "#0000ffff" }
+        ]
+    }
+];
+
+PS.gradientPresetById = function (id) {
+    for (var i = 0; i < PS.gradientPresets.length; i++) {
+        if (PS.gradientPresets[i].id === id) { return PS.gradientPresets[i]; }
+    }
+    return null;
+};
+
+// transparent / opaque variants of a hex color
+function withAlpha(hex, a) {
+    var c = PS.hexToRgb(hex) || { r: 0, g: 0, b: 0 };
+    return PS.rgbToHex(c.r, c.g, c.b, a);
+}
+
+// resolve a dynamic (FG/BG) preset against the current colors
+function dynStops(id) {
+    switch (id) {
+        case "fg-bg": return [{ pos: 0, color: PS.fg }, { pos: 1, color: PS.bg }];
+        case "fg-transparent": return [{ pos: 0, color: withAlpha(PS.fg, 255) }, { pos: 1, color: withAlpha(PS.fg, 0) }];
+        case "fg-white": return [{ pos: 0, color: PS.fg }, { pos: 1, color: "#ffffff" }];
+        case "fg-black": return [{ pos: 0, color: PS.fg }, { pos: 1, color: "#000000" }];
+    }
+    return [{ pos: 0, color: "#000000" }, { pos: 1, color: "#ffffff" }];
+}
+
+function presetStops(p) {
+    if (p.dyn) { return dynStops(p.id); }
+    return p.stops.map(function (s) { return { pos: s.pos, color: s.color }; });
+}
+
+// the stops the tool will actually paint with
+PS.activeGradientStops = function () {
+    var g = PS.toolOpts.gradient;
+    var p = PS.gradientPresetById(g.preset);
+    if (p && p.dyn) { return dynStops(p.id); }
+    if (g.stops && g.stops.length >= 2) { return g.stops; }
+    if (p && p.stops) { return presetStops(p); }
+    return [{ pos: 0, color: "#000000" }, { pos: 1, color: "#ffffff" }];
+};
+
+/* ---------- gradient construction + painting ---------- */
+
+PS.buildCanvasGradient = function (ctx, s, e, style, stops) {
+    var grad, i;
+    if (style === "radial") {
+        var r = Math.hypot(e.x - s.x, e.y - s.y) || 1;
+        grad = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, r);
+        for (i = 0; i < stops.length; i++) { grad.addColorStop(PS.clamp(stops[i].pos, 0, 1), stops[i].color); }
+    } else if (style === "reflected") {
+        grad = ctx.createLinearGradient(2 * s.x - e.x, 2 * s.y - e.y, e.x, e.y);
+        for (i = 0; i < stops.length; i++) {
+            var p = PS.clamp(stops[i].pos, 0, 1);
+            grad.addColorStop(PS.clamp(0.5 + 0.5 * p, 0, 1), stops[i].color);
+            grad.addColorStop(PS.clamp(0.5 - 0.5 * p, 0, 1), stops[i].color);
+        }
+    } else {
+        grad = ctx.createLinearGradient(s.x, s.y, e.x, e.y);
+        for (i = 0; i < stops.length; i++) { grad.addColorStop(PS.clamp(stops[i].pos, 0, 1), stops[i].color); }
+    }
+    return grad;
+};
+
+PS.applyGradient = function (start, end) {
+    var layer = PS.requirePaintableLayer();
+    if (!layer) { return; }
+    var g = PS.toolOpts.gradient;
+    var stops = PS.activeGradientStops();
+    if (g.reverse) {
+        stops = stops.map(function (s) { return { pos: 1 - s.pos, color: s.color }; });
+    }
+    var before = PS.snapshotLayer(layer);
+    PS.maskedDraw(layer, function (ctx) {
+        var grad = PS.buildCanvasGradient(ctx, start, end, g.style, stops);
+        ctx.save();
+        ctx.globalAlpha = (g.opacity === undefined ? 1 : g.opacity);
+        ctx.fillStyle = grad;
+        ctx.fillRect(0, 0, PS.doc.width, PS.doc.height);
+        ctx.restore();
+    });
+    PS.commitLayerCanvas("Gradient", layer, before);
+    PS.requestRender();
+};
+
+/* ---------- stop interpolation + preview rendering (shared) ---------- */
+
+PS.gradientColorAt = function (stops, pos) {
+    var sorted = stops.slice().sort(function (a, b) { return a.pos - b.pos; });
+    if (pos <= sorted[0].pos) { return sorted[0].color; }
+    var last = sorted[sorted.length - 1];
+    if (pos >= last.pos) { return last.color; }
+    for (var i = 0; i < sorted.length - 1; i++) {
+        var a = sorted[i], b = sorted[i + 1];
+        if (pos >= a.pos && pos <= b.pos) {
+            var t = (pos - a.pos) / ((b.pos - a.pos) || 1);
+            var ca = PS.hexToRgb(a.color), cb = PS.hexToRgb(b.color);
+            return PS.rgbToHex(ca.r + (cb.r - ca.r) * t, ca.g + (cb.g - ca.g) * t,
+                ca.b + (cb.b - ca.b) * t, ca.a + (cb.a - ca.a) * t);
+        }
+    }
+    return sorted[0].color;
+};
+
+PS.renderGradientBar = function (canvas, stops) {
+    var ctx = canvas.getContext("2d");
+    var w = canvas.width, h = canvas.height, cs = 6, x, y;
+    for (y = 0; y < h; y += cs) {
+        for (x = 0; x < w; x += cs) {
+            ctx.fillStyle = ((Math.floor(x / cs) + Math.floor(y / cs)) % 2 === 0) ? "#bbb" : "#777";
+            ctx.fillRect(x, y, cs, cs);
+        }
+    }
+    var g = ctx.createLinearGradient(0, 0, w, 0);
+    stops.slice().sort(function (a, b) { return a.pos - b.pos; }).forEach(function (s) {
+        g.addColorStop(PS.clamp(s.pos, 0, 1), s.color);
+    });
+    ctx.fillStyle = g;
+    ctx.fillRect(0, 0, w, h);
+};
+
+/* ---------- the tool ---------- */
+
+(function () {
+    var drag = null;
+
+    function constrainEnd(s, e, on) {
+        if (!on) { return e; }
+        var dx = e.x - s.x, dy = e.y - s.y;
+        var ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
+        var len = Math.hypot(dx, dy);
+        return { x: s.x + len * Math.cos(ang), y: s.y + len * Math.sin(ang) };
+    }
+
+    PS.registerTool("gradient", {
+        name: "Gradient",
+        key: "g",
+        cursor: "crosshair",
+        icon: '<svg viewBox="0 0 24 24" stroke-width="1.6"><rect x="4" y="5" width="16" height="14" rx="1"/><path d="M4 19 20 5"/></svg>',
+        options: function (host) {
+            var o = PS.toolOpts.gradient;
+
+            // live preview + open the editor
+            var g = PS.ui.group(host);
+            PS.ui.label(g, "Gradient");
+            var prev = document.createElement("canvas");
+            prev.width = 90; prev.height = 18;
+            prev.className = "grad-opt-preview";
+            prev.title = "Click to edit the gradient";
+            PS.renderGradientBar(prev, PS.activeGradientStops());
+            prev.addEventListener("click", function () { PS.openGradientEditor(); });
+            g.appendChild(prev);
+
+            PS.ui.select(host, "Style", [
+                { v: "linear", l: "Linear" },
+                { v: "radial", l: "Radial" },
+                { v: "reflected", l: "Reflected" }
+            ], o.style, function (v) { o.style = v; PS.savePrefsDebounced(); });
+
+            PS.ui.slider(host, "Opacity", Math.round(o.opacity * 100), 1, 100, 1, function (v) {
+                o.opacity = v / 100; PS.savePrefsDebounced();
+            }, function (v) { return v + "%"; });
+
+            PS.ui.checkbox(host, "Reverse", o.reverse, function (v) {
+                o.reverse = v; PS.savePrefsDebounced();
+            });
+
+            PS.ui.label(host, "Drag on the canvas to apply. Shift constrains the angle.");
+        },
+        onDown: function (pt, e) {
+            if (!PS.requirePaintableLayer()) { return; }
+            drag = { start: pt, cur: pt, constrain: e.shiftKey };
+        },
+        onMove: function (pt, e) {
+            if (!drag) { return; }
+            drag.cur = pt;
+            drag.constrain = e.shiftKey;
+        },
+        onUp: function () {
+            if (!drag) { return; }
+            var s = drag.start, e = constrainEnd(drag.start, drag.cur, drag.constrain);
+            drag = null;
+            if (Math.hypot(e.x - s.x, e.y - s.y) < 2) { return; }
+            PS.applyGradient(s, e);
+        },
+        overlay: function (ctx) {
+            if (!drag) { return; }
+            var e = constrainEnd(drag.start, drag.cur, drag.constrain);
+            PS.overlayDocSpace(ctx, function (px) {
+                ctx.lineWidth = 1.5 * px;
+                ctx.strokeStyle = "rgba(255,255,255,0.95)";
+                ctx.beginPath();
+                ctx.moveTo(drag.start.x, drag.start.y);
+                ctx.lineTo(e.x, e.y);
+                ctx.stroke();
+                ctx.strokeStyle = "rgba(0,0,0,0.9)";
+                ctx.setLineDash([4 * px, 3 * px]);
+                ctx.stroke();
+                ctx.setLineDash([]);
+                // endpoint dots
+                [drag.start, e].forEach(function (p) {
+                    ctx.fillStyle = "#fff"; ctx.strokeStyle = "#000"; ctx.lineWidth = px;
+                    ctx.beginPath(); ctx.arc(p.x, p.y, 3 * px, 0, Math.PI * 2);
+                    ctx.fill(); ctx.stroke();
+                });
+            });
+        }
+    });
+}());
+
+/* ---------- windowed gradient editor ---------- */
+
+PS.openGradientEditor = function () {
+    var g = PS.toolOpts.gradient;
+    // working copy of the current stops (sorted)
+    var stops = PS.activeGradientStops().map(function (s) { return { pos: s.pos, color: s.color }; });
+    var selected = 0;
+    var bar, track, ctrls, presetHost;
+
+    function sortStops() {
+        stops.sort(function (a, b) { return a.pos - b.pos; });
+    }
+
+    function markCustom() {
+        g.preset = "custom";
+        g.stops = stops.map(function (s) { return { pos: s.pos, color: s.color }; });
+        PS.savePrefsDebounced();
+        if (PS.tool === "gradient") { PS.renderOptionsBar(); }
+    }
+
+    function selectPreset(p) {
+        stops = presetStops(p);
+        sortStops();
+        selected = 0;
+        g.preset = p.id;
+        g.stops = stops.map(function (s) { return { pos: s.pos, color: s.color }; });
+        PS.savePrefsDebounced();
+        if (PS.tool === "gradient") { PS.renderOptionsBar(); }
+        refresh();
+    }
+
+    function refresh() {
+        PS.renderGradientBar(bar, stops);
+        renderTrack();
+        renderCtrls();
+        highlightPreset();
+    }
+
+    function renderTrack() {
+        track.innerHTML = "";
+        stops.forEach(function (s, i) {
+            var m = document.createElement("div");
+            m.className = "grad-stop" + (i === selected ? " selected" : "");
+            m.style.left = (PS.clamp(s.pos, 0, 1) * 100) + "%";
+            var sw = document.createElement("i");
+            sw.style.background = s.color;
+            m.appendChild(sw);
+            track.appendChild(m);
+        });
+    }
+
+    function renderCtrls() {
+        ctrls.innerHTML = "";
+        if (!stops.length) { return; }
+        var s = stops[selected];
+
+        var well = document.createElement("button");
+        well.className = "grad-stop-well";
+        well.style.background = s.color;
+        well.title = "Edit stop color (alpha = transparency)";
+        well.addEventListener("click", function () {
+            PS.openColorPicker("grad-stop", {
+                initial: s.color,
+                title: "Stop Color",
+                onChange: function (hex) {
+                    s.color = hex;
+                    well.style.background = hex;
+                    PS.renderGradientBar(bar, stops);
+                    renderTrack();
+                    markCustom();
+                }
+            });
+        });
+        ctrls.appendChild(well);
+
+        var posWrap = document.createElement("div");
+        posWrap.className = "grad-pos";
+        var lab = document.createElement("label");
+        lab.textContent = "Pos %";
+        var posInp = document.createElement("input");
+        posInp.type = "number"; posInp.min = 0; posInp.max = 100;
+        posInp.value = Math.round(s.pos * 100);
+        posInp.addEventListener("input", function () {
+            var v = PS.clamp(parseFloat(posInp.value) || 0, 0, 100) / 100;
+            s.pos = v;
+            PS.renderGradientBar(bar, stops);
+            renderTrack();
+            markCustom();
+        });
+        posWrap.appendChild(lab);
+        posWrap.appendChild(posInp);
+        ctrls.appendChild(posWrap);
+
+        var del = document.createElement("button");
+        del.className = "grad-del";
+        del.textContent = "Delete";
+        del.disabled = stops.length <= 2;
+        del.addEventListener("click", function () {
+            if (stops.length <= 2) { return; }
+            stops.splice(selected, 1);
+            selected = PS.clamp(selected, 0, stops.length - 1);
+            markCustom();
+            refresh();
+        });
+        ctrls.appendChild(del);
+    }
+
+    function highlightPreset() {
+        if (!presetHost) { return; }
+        Array.prototype.forEach.call(presetHost.children, function (t) {
+            t.classList.toggle("active", t.dataset.id === g.preset);
+        });
+    }
+
+    PS.floatingPanel({
+        title: "Gradient Editor",
+        x: Math.round(window.innerWidth / 2 - 170),
+        y: 96,
+        build: function (body) {
+            body.classList.add("grad-editor");
+
+            // presets
+            var pl = document.createElement("div");
+            pl.className = "swatch-label";
+            pl.textContent = "Presets";
+            body.appendChild(pl);
+
+            presetHost = document.createElement("div");
+            presetHost.className = "grad-presets";
+            PS.gradientPresets.forEach(function (p) {
+                var t = document.createElement("canvas");
+                t.width = 52; t.height = 20;
+                t.className = "grad-thumb";
+                t.dataset.id = p.id;
+                t.title = p.label;
+                PS.renderGradientBar(t, presetStops(p));
+                t.addEventListener("click", function () { selectPreset(p); });
+                presetHost.appendChild(t);
+            });
+            body.appendChild(presetHost);
+
+            // live bar + stop track
+            var barWrap = document.createElement("div");
+            barWrap.className = "grad-bar-wrap";
+            bar = document.createElement("canvas");
+            bar.width = 300; bar.height = 26;
+            bar.className = "grad-bar";
+            barWrap.appendChild(bar);
+            track = document.createElement("div");
+            track.className = "grad-track";
+            barWrap.appendChild(track);
+            body.appendChild(barWrap);
+
+            ctrls = document.createElement("div");
+            ctrls.className = "grad-stop-controls";
+            body.appendChild(ctrls);
+
+            var hint = document.createElement("div");
+            hint.className = "swatch-hint";
+            hint.textContent = "Click the bar to add a stop; drag stops to move; click a stop to edit.";
+            body.appendChild(hint);
+
+            bindTrack();
+            refresh();
+        },
+        buttons: [{ label: "Done", primary: true }]
+    });
+
+    /* ----- track interaction (add / select / drag stops) ----- */
+    function bindTrack() {
+        var dragging = null;
+        function posFromEvent(e) {
+            var r = track.getBoundingClientRect();
+            return PS.clamp((e.clientX - r.left) / r.width, 0, 1);
+        }
+        function hitStop(pos) {
+            var best = -1, bd = 0.04;
+            stops.forEach(function (s, i) {
+                var d = Math.abs(s.pos - pos);
+                if (d <= bd) { bd = d; best = i; }
+            });
+            return best;
+        }
+        function down(e) {
+            var pos = posFromEvent(e);
+            var hit = hitStop(pos);
+            if (hit < 0) {
+                stops.push({ pos: pos, color: PS.gradientColorAt(stops, pos) });
+                sortStops();
+                selected = -1;
+                stops.forEach(function (s, i) { if (s.pos === pos) { selected = i; } });
+                markCustom();
+            } else {
+                selected = hit;
+            }
+            dragging = selected;
+            track.setPointerCapture(e.pointerId);
+            refresh();
+            e.preventDefault();
+        }
+        function move(e) {
+            if (dragging === null || !(e.buttons & 1)) { return; }
+            stops[selected].pos = posFromEvent(e);
+            PS.renderGradientBar(bar, stops);
+            renderTrack();
+        }
+        function up() {
+            if (dragging === null) { return; }
+            dragging = null;
+            sortStops();
+            // keep the same stop selected after sorting
+            markCustom();
+            refresh();
+        }
+        track.addEventListener("pointerdown", down);
+        track.addEventListener("pointermove", move);
+        track.addEventListener("pointerup", up);
+        track.addEventListener("pointercancel", up);
+        // clicking the bar itself adds a stop too
+        bar.addEventListener("pointerdown", down);
+    }
+};

+ 2 - 1
src/web/Pixel Studio/js/hotkeys.js

@@ -41,7 +41,8 @@ PS.handlePaste = function (e) {
 PS._toolGroups = {
     m: ["marquee-rect", "marquee-ellipse"],
     l: ["lasso", "lasso-poly"],
-    b: ["brush", "pencil"]
+    b: ["brush", "pencil"],
+    g: ["fill", "gradient"]
 };
 
 PS._toolKeys = {

+ 3 - 2
src/web/Pixel Studio/js/tools.js

@@ -12,6 +12,7 @@ PS.toolOpts = {
     pencil: { size: 3, opacity: 1 },
     eraser: { size: 30, opacity: 1, type: "round" },
     fill: { tolerance: 32, contiguous: true },
+    gradient: { preset: "fg-bg", style: "linear", reverse: false, opacity: 1, stops: [] },
     wand: { tolerance: 32, contiguous: true, smart: true, edgeThreshold: 60 },
     marquee: { feather: 0 },
     shape: { kind: "rect", mode: "both", strokeWidth: 6, radius: 12, points: 5 },
@@ -29,7 +30,7 @@ PS.toolbarLayout = [
     { kind: "single", tool: "wand" },
     { kind: "group", id: "paint", tools: ["brush", "pencil"] },
     { kind: "single", tool: "eraser" },
-    { kind: "single", tool: "fill" },
+    { kind: "group", id: "bucket", tools: ["fill", "gradient"] },
     { kind: "single", tool: "eyedropper" },
     { kind: "single", tool: "text" },
     { kind: "shape" },
@@ -38,7 +39,7 @@ PS.toolbarLayout = [
 ];
 
 // Last-selected member shown on each group's toolbar button.
-PS.groupRep = { select: "marquee-rect", paint: "brush" };
+PS.groupRep = { select: "marquee-rect", paint: "brush", bucket: "fill" };
 
 // Per-shape-kind icons for the shape fly-out and toolbar button.
 PS.shapeIcons = {