ソースを参照

Add free resize on select tool

Toby Chui 12 時間 前
コミット
23da9ebcf4

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

@@ -782,6 +782,13 @@ input, select, button, textarea {
     font-size: 13px;
 }
 
+.layer-eye svg {
+    width: 14px;
+    height: 14px;
+    stroke: currentColor;
+    fill: none;
+}
+
 .layer-thumb {
     width: 42px;
     height: 32px;
@@ -840,6 +847,16 @@ input, select, button, textarea {
     color: var(--text);
     font-size: 13px;
     line-height: 1;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.layers-footer button svg {
+    width: 14px;
+    height: 14px;
+    stroke: currentColor;
+    fill: none;
 }
 
 .layers-footer button:hover { background: var(--bg-input); }

+ 14 - 3
src/web/Pixel Studio/js/layers.js

@@ -59,6 +59,14 @@ PS.addLayer = function (name, opts) {
     return layer;
 };
 
+// doc-space {x,y,w,h} of a layer's actual content (trimmed to opaque pixels
+// for raster layers, font metrics for text layers), or null if empty
+PS.layerContentBounds = function (layer) {
+    if (!layer) { return null; }
+    if (layer.type === "text") { return PS.textLayerBounds(layer); }
+    return PS.maskBounds(layer.canvas);
+};
+
 PS.deleteLayer = function () {
     var d = PS.doc;
     if (d.layers.length <= 1) { PS.toast("Cannot delete the last layer", true); return; }
@@ -286,10 +294,11 @@ PS.renderLayersPanel = function () {
         ["▲", "Move layer up", function () { PS.moveLayer(1); }],
         ["▼", "Move layer down", function () { PS.moveLayer(-1); }],
         ["⇊", "Merge down (Ctrl+E)", function () { PS.mergeDown(); }],
-        ["🗑", "Delete layer", function () { PS.deleteLayer(); }]
+        ['<svg viewBox="0 0 24 24" stroke-width="1.6"><path d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13"/></svg>',
+            "Delete layer", function () { PS.deleteLayer(); }]
     ].forEach(function (def) {
         var btn = document.createElement("button");
-        btn.textContent = def[0];
+        btn.innerHTML = def[0];
         btn.title = def[1];
         btn.addEventListener("click", def[2]);
         footer.appendChild(btn);
@@ -308,7 +317,9 @@ PS._buildLayerRow = function (layer, index) {
 
     var eye = document.createElement("div");
     eye.className = "layer-eye";
-    eye.textContent = layer.visible ? "👁" : "—";
+    eye.innerHTML = layer.visible
+        ? '<svg viewBox="0 0 24 24" stroke-width="1.6"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>'
+        : "—";
     eye.title = "Toggle visibility";
     eye.addEventListener("click", function (e) {
         e.stopPropagation();

+ 5 - 3
src/web/Pixel Studio/js/selection.js

@@ -543,10 +543,12 @@ PS.selTransform = (function () {
             return h ? CURSOR[h] : null;
         },
 
-        // Call on pointerdown; returns true if a handle was grabbed
-        onDown: function (pt) {
+        // Call on pointerdown; returns true if a handle was grabbed.
+        // forceHandle lets a caller (the Move tool's content-box corners) grab
+        // a specific handle without relying on sub-pixel hit-testing.
+        onDown: function (pt, e, forceHandle) {
             if (!isSelTool() || !PS.doc || !PS.doc.selection) { return false; }
-            var h = hitHandle(pt);
+            var h = forceHandle || hitHandle(pt);
             if (!h) { return false; }
             var sel = PS.doc.selection;
             var layer = PS.activeLayer();

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

@@ -15,6 +15,7 @@ PS.toolOpts = {
     gradient: { preset: "fg-bg", style: "linear", reverse: false, opacity: 1, stops: [] },
     wand: { tolerance: 32, contiguous: true, smart: true, edgeThreshold: 60 },
     marquee: { feather: 0 },
+    move: { showBounds: false },
     shape: { kind: "rect", mode: "both", strokeWidth: 6, radius: 12, points: 5 },
     text: { font: "Arial", size: 48, bold: false, italic: false },
     zoom: {}
@@ -304,7 +305,7 @@ PS.bindWorkspaceEvents = function () {
 
         // Update cursor for handle / guide hover (only when not mid-stroke/drag)
         if (!PS._pointer.down) {
-            var tCursor = PS.selTransform.getCursor(PS.cursorPos);
+            var tCursor = PS.selTransform.getCursor(PS.cursorPos) || PS.moveBoundsCursor(PS.cursorPos);
             if (!tCursor) {
                 var gh = PS.guideHitTest(raw);
                 if (gh) { tCursor = (gh.orient === "h") ? "row-resize" : "col-resize"; }
@@ -638,6 +639,40 @@ PS.paintCursorOverlay = function (size, square) {
 /* ----- Move (V) ----- */
 (function () {
     var drag = null;
+    var HANDLE_PX = 8;   // corner handle square size, screen pixels
+    var PAD_PX = 6;      // gap between content bounds and the drawn box, screen pixels
+    // matches the diagonal resize cursors selTransform uses for its own
+    // corner handles, so the affordance reads the same even though clicking
+    // here switches tools instead of resizing in place
+    var CORNER_CURSOR = { nw: "nwse-resize", ne: "nesw-resize", se: "nwse-resize", sw: "nesw-resize" };
+
+    // 4 corner positions (doc coords) of a content-bounds box, padded the
+    // same way the box itself is drawn
+    function cornerPositions(b) {
+        var pad = PAD_PX / PS.zoom;
+        var x = b.x - pad, y = b.y - pad, r = b.x + b.w + pad, bot = b.y + b.h + pad;
+        return { nw: { x: x, y: y }, ne: { x: r, y: y }, se: { x: r, y: bot }, sw: { x: x, y: bot } };
+    }
+
+    // returns the corner id under doc point pt, or null
+    function hitCorner(pt, b) {
+        var corners = cornerPositions(b);
+        var hitR = (HANDLE_PX / 2 + 3) / PS.zoom;
+        for (var id in corners) {
+            var c = corners[id];
+            if (Math.abs(pt.x - c.x) <= hitR && Math.abs(pt.y - c.y) <= hitR) { return id; }
+        }
+        return null;
+    }
+
+    // cursor hint for the central pointer pipeline (hover, not dragging)
+    PS.moveBoundsCursor = function (pt) {
+        if (!pt || PS.tool !== "move" || !PS.toolOpts.move.showBounds) { return null; }
+        var layer = PS.activeLayer();
+        var b = layer && PS.layerContentBounds(layer);
+        var id = b && hitCorner(pt, b);
+        return id ? CORNER_CURSOR[id] : null;
+    };
 
     PS.registerTool("move", {
         name: "Move",
@@ -645,12 +680,36 @@ PS.paintCursorOverlay = function (size, square) {
         cursor: "move",
         icon: '<svg viewBox="0 0 24 24" stroke-width="1.6"><path d="M12 2v20M2 12h20M12 2l-3 3M12 2l3 3M12 22l-3-3M12 22l3-3M2 12l3-3M2 12l3 3M22 12l-3-3M22 12l-3 3"/></svg>',
         options: function (host) {
-            PS.ui.label(host, "Drag to move the active layer; with a selection, drags the selected pixels. Arrow keys nudge.");
+            var o = PS.toolOpts.move;
+            PS.ui.checkbox(host, "Show selection box", o.showBounds, function (v) {
+                o.showBounds = v;
+                PS.savePrefsDebounced();
+            });
+            PS.ui.label(host, "Drag to move the active layer; with a selection, drags the selected pixels. Arrow keys nudge. " +
+                "With the box shown, click a corner to switch to the rectangular marquee.");
         },
         onDown: function (pt, e) {
             var layer = PS.activeLayer();
             if (!layer) { return; }
 
+            if (PS.toolOpts.move.showBounds) {
+                var cb = PS.layerContentBounds(layer);
+                var corner = cb && hitCorner(pt, cb);
+                if (corner) {
+                    // Switch to the rectangular marquee and immediately begin a
+                    // resize-handle drag, so dragging the corner *scales the
+                    // layer content* (not just draws a new selection). We seed a
+                    // selection over the content bounds so the transform engine
+                    // has a handle at the clicked corner to grab; the scale runs
+                    // on this first drag, matching the marquee's own handles.
+                    var mask = PS.maskFromRect(cb.x, cb.y, cb.w, cb.h, false);
+                    PS.setSelection(mask, "replace", "Select Layer Bounds");
+                    PS.setTool("marquee-rect");
+                    PS.selTransform.onDown(pt, e, corner);
+                    return;
+                }
+            }
+
             if (layer.type === "text") {
                 drag = { mode: "text", layer: layer, start: pt, ox: layer.text.x, oy: layer.text.y };
                 return;
@@ -744,6 +803,49 @@ PS.paintCursorOverlay = function (size, square) {
             }
             drag = null;
             PS.requestRender();
+        },
+        overlay: function (ctx) {
+            if (!PS.toolOpts.move.showBounds) { return; }
+            var layer = drag ? drag.layer : PS.activeLayer();
+            if (!layer) { return; }
+            var b = PS.layerContentBounds(layer);
+            if (!b) { return; }
+            if (drag && (drag.dx || drag.dy)) { b = { x: b.x + drag.dx, y: b.y + drag.dy, w: b.w, h: b.h }; }
+
+            var z = PS.zoom;
+            var origin = PS.docToOverlay(0, 0);
+            var pad = PAD_PX;
+            var sx = origin.x + b.x * z - pad, sy = origin.y + b.y * z - pad;
+            var sw = b.w * z + pad * 2, sh = b.h * z + pad * 2;
+
+            ctx.save();
+            ctx.strokeStyle = "rgba(100,160,255,0.85)";
+            ctx.lineWidth = 1;
+            ctx.setLineDash([4, 3]);
+            ctx.strokeRect(Math.round(sx) + 0.5, Math.round(sy) + 0.5, Math.round(sw), Math.round(sh));
+            ctx.setLineDash([]);
+
+            var hs = HANDLE_PX, hh = hs / 2;
+            [{ x: sx, y: sy }, { x: sx + sw, y: sy }, { x: sx + sw, y: sy + sh }, { x: sx, y: sy + sh }]
+                .forEach(function (hp) {
+                    var hx = Math.round(hp.x), hy = Math.round(hp.y);
+                    ctx.fillStyle = "rgba(30,30,30,0.75)";
+                    ctx.fillRect(hx - hh - 1, hy - hh - 1, hs + 2, hs + 2);
+                    ctx.fillStyle = "#ffffff";
+                    ctx.fillRect(hx - hh, hy - hh, hs, hs);
+                });
+
+            // dimension label
+            var label = Math.round(b.w) + " x " + Math.round(b.h) + " px";
+            ctx.font = "11px sans-serif";
+            var tw = ctx.measureText(label).width;
+            var ly = sy - 8;
+            ctx.fillStyle = "rgba(30,30,30,0.85)";
+            ctx.fillRect(sx, ly - 12, tw + 8, 16);
+            ctx.fillStyle = "#ffffff";
+            ctx.textBaseline = "middle";
+            ctx.fillText(label, sx + 4, ly - 4);
+            ctx.restore();
         }
     });