3 Commits 8bcf24fc6e ... 23da9ebcf4

Author SHA1 Message Date
  Toby Chui 23da9ebcf4 Add free resize on select tool 6 days ago
  Toby Chui 7f3ace4d87 Add text color change inline 6 days ago
  Toby Chui 600ae9d35b Fixed #204 6 days ago

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

@@ -377,6 +377,14 @@ input, select, button, textarea {
     min-height: 1em;
     min-height: 1em;
 }
 }
 
 
+/* the textarea's own glyphs and native selection stay fully invisible; the
+   real text (and its selection highlight) are drawn on the canvas layers
+   underneath so they can never visually drift from the textarea's caret */
+#text-edit-host .text-editor::selection {
+    background: transparent;
+    color: transparent;
+}
+
 /* ============ Rulers & guides ============ */
 /* ============ Rulers & guides ============ */
 #ruler-top, #ruler-left, #ruler-corner {
 #ruler-top, #ruler-left, #ruler-corner {
     position: absolute;
     position: absolute;
@@ -774,6 +782,13 @@ input, select, button, textarea {
     font-size: 13px;
     font-size: 13px;
 }
 }
 
 
+.layer-eye svg {
+    width: 14px;
+    height: 14px;
+    stroke: currentColor;
+    fill: none;
+}
+
 .layer-thumb {
 .layer-thumb {
     width: 42px;
     width: 42px;
     height: 32px;
     height: 32px;
@@ -832,6 +847,16 @@ input, select, button, textarea {
     color: var(--text);
     color: var(--text);
     font-size: 13px;
     font-size: 13px;
     line-height: 1;
     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); }
 .layers-footer button:hover { background: var(--bg-input); }

+ 13 - 2
src/web/Pixel Studio/js/editor.js

@@ -355,6 +355,9 @@ PS.startOverlayLoop = function () {
             }
             }
             // selection transform handles (visible when a selection tool is active)
             // selection transform handles (visible when a selection tool is active)
             PS.selTransform.drawOverlay(ctx);
             PS.selTransform.drawOverlay(ctx);
+            // active text edit's selection highlight (kept in sync with the
+            // real canvas-rendered glyphs; see PS.drawTextEditSelection)
+            if (PS.textEdit) { PS.drawTextEditSelection(ctx); }
             // active tool overlay (shape previews, lasso paths, brush cursor...)
             // active tool overlay (shape previews, lasso paths, brush cursor...)
             var tool = PS.tools[PS.tool];
             var tool = PS.tools[PS.tool];
             if (tool && tool.overlay) {
             if (tool && tool.overlay) {
@@ -717,6 +720,7 @@ PS.setFg = function (hex, skipRecent) {
     var hexInp = document.querySelector("#panel-color-body .color-hex");
     var hexInp = document.querySelector("#panel-color-body .color-hex");
     if (hexInp) { hexInp.value = hex; }
     if (hexInp) { hexInp.value = hex; }
     if (!skipRecent) { PS.pushRecentColor(hex); }
     if (!skipRecent) { PS.pushRecentColor(hex); }
+    if (PS.textEdit) { PS.applyTextColorFromSelection(hex); }
     PS.savePrefsDebounced();
     PS.savePrefsDebounced();
 };
 };
 
 
@@ -825,6 +829,11 @@ PS.renderColorPanel = function () {
     row.appendChild(pick);
     row.appendChild(pick);
     body.appendChild(row);
     body.appendChild(row);
 
 
+    var swatchHint = document.createElement("div");
+    swatchHint.className = "swatch-hint";
+    swatchHint.textContent = "Click a swatch for foreground, Ctrl+Click for background.";
+    body.appendChild(swatchHint);
+
     // swatch grid; onpick selects FG, onremove (optional) right-click removes
     // swatch grid; onpick selects FG, onremove (optional) right-click removes
     function addGrid(colors, onRemove) {
     function addGrid(colors, onRemove) {
         var grid = document.createElement("div");
         var grid = document.createElement("div");
@@ -833,8 +842,10 @@ PS.renderColorPanel = function () {
             var s = document.createElement("div");
             var s = document.createElement("div");
             s.className = "swatch";
             s.className = "swatch";
             s.style.background = c;
             s.style.background = c;
-            s.title = c + (onRemove ? "  (right-click to remove)" : "");
-            s.addEventListener("click", function () { PS.setFg(c, true); });
+            s.title = c + "  (Ctrl+Click: set background)" + (onRemove ? ", right-click: remove" : "");
+            s.addEventListener("click", function (e) {
+                if (e.ctrlKey || e.metaKey) { PS.setBg(c); } else { PS.setFg(c, true); }
+            });
             if (onRemove) {
             if (onRemove) {
                 s.addEventListener("contextmenu", function (e) {
                 s.addEventListener("contextmenu", function (e) {
                     e.preventDefault();
                     e.preventDefault();

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

@@ -59,6 +59,14 @@ PS.addLayer = function (name, opts) {
     return layer;
     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 () {
 PS.deleteLayer = function () {
     var d = PS.doc;
     var d = PS.doc;
     if (d.layers.length <= 1) { PS.toast("Cannot delete the last layer", true); return; }
     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 up", function () { PS.moveLayer(1); }],
         ["▼", "Move layer down", function () { PS.moveLayer(-1); }],
         ["▼", "Move layer down", function () { PS.moveLayer(-1); }],
         ["⇊", "Merge down (Ctrl+E)", function () { PS.mergeDown(); }],
         ["⇊", "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) {
     ].forEach(function (def) {
         var btn = document.createElement("button");
         var btn = document.createElement("button");
-        btn.textContent = def[0];
+        btn.innerHTML = def[0];
         btn.title = def[1];
         btn.title = def[1];
         btn.addEventListener("click", def[2]);
         btn.addEventListener("click", def[2]);
         footer.appendChild(btn);
         footer.appendChild(btn);
@@ -308,7 +317,9 @@ PS._buildLayerRow = function (layer, index) {
 
 
     var eye = document.createElement("div");
     var eye = document.createElement("div");
     eye.className = "layer-eye";
     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.title = "Toggle visibility";
     eye.addEventListener("click", function (e) {
     eye.addEventListener("click", function (e) {
         e.stopPropagation();
         e.stopPropagation();

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

@@ -543,10 +543,12 @@ PS.selTransform = (function () {
             return h ? CURSOR[h] : null;
             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; }
             if (!isSelTool() || !PS.doc || !PS.doc.selection) { return false; }
-            var h = hitHandle(pt);
+            var h = forceHandle || hitHandle(pt);
             if (!h) { return false; }
             if (!h) { return false; }
             var sel = PS.doc.selection;
             var sel = PS.doc.selection;
             var layer = PS.activeLayer();
             var layer = PS.activeLayer();

+ 124 - 9
src/web/Pixel Studio/js/text.js

@@ -60,18 +60,88 @@ PS.textFontString = function (t) {
         t.size + "px \"" + t.font + "\"";
         t.size + "px \"" + t.font + "\"";
 };
 };
 
 
+// color of the character at absolute offset `pos` within t.content, falling
+// back to the text's base color outside of any colored range
+PS.textColorAt = function (t, pos) {
+    var ranges = t.colorRanges || [];
+    for (var i = 0; i < ranges.length; i++) {
+        if (pos >= ranges[i].start && pos < ranges[i].end) { return ranges[i].color; }
+    }
+    return t.color;
+};
+
+// paints [start, end) of t.content in `color`, splitting/trimming any
+// existing ranges that overlap the new one
+PS.setTextColorRange = function (t, start, end, color) {
+    if (start === end) { return; }
+    if (start > end) { var tmp = start; start = end; end = tmp; }
+    var ranges = t.colorRanges || [];
+    var next = [];
+    ranges.forEach(function (r) {
+        if (r.end <= start || r.start >= end) { next.push(r); return; }
+        if (r.start < start) { next.push({ start: r.start, end: start, color: r.color }); }
+        if (r.end > end) { next.push({ start: end, end: r.end, color: r.color }); }
+    });
+    next.push({ start: start, end: end, color: color });
+    next.sort(function (a, b) { return a.start - b.start; });
+    t.colorRanges = next;
+};
+
+// applies `hex` to the active text edit's selection, or to the whole text
+// (clearing any per-range colors) when nothing - or everything - is
+// selected; re-renders immediately so the change is visible while still
+// editing, not just after the edit is committed
+PS.applyTextColorFromSelection = function (hex) {
+    var te = PS.textEdit;
+    if (!te) { return; }
+    var ed = te.editorEl;
+    var t = te.layer.text;
+    var start = ed.selectionStart, end = ed.selectionEnd;
+    if (start == null) { start = end = 0; }
+    var isWholeText = (start === end) || (start === 0 && end === t.content.length);
+    if (isWholeText) {
+        t.color = hex;
+        t.colorRanges = [];
+    } else {
+        PS.setTextColorRange(t, start, end, hex);
+    }
+    PS.renderTextLayer(te.layer);
+    PS.requestRender();
+};
+
 PS.renderTextLayer = function (layer) {
 PS.renderTextLayer = function (layer) {
     if (layer.type !== "text" || !layer.text) { return; }
     if (layer.type !== "text" || !layer.text) { return; }
     var t = layer.text;
     var t = layer.text;
     var ctx = layer.canvas.getContext("2d");
     var ctx = layer.canvas.getContext("2d");
     ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
     ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
     ctx.font = PS.textFontString(t);
     ctx.font = PS.textFontString(t);
-    ctx.fillStyle = t.color;
     ctx.textBaseline = "top";
     ctx.textBaseline = "top";
     var lineHeight = Math.round(t.size * 1.25);
     var lineHeight = Math.round(t.size * 1.25);
     var lines = (t.content || "").split("\n");
     var lines = (t.content || "").split("\n");
+    var hasRanges = t.colorRanges && t.colorRanges.length;
+    var offset = 0;
     for (var i = 0; i < lines.length; i++) {
     for (var i = 0; i < lines.length; i++) {
-        ctx.fillText(lines[i], t.x, t.y + i * lineHeight);
+        var line = lines[i];
+        var y = t.y + i * lineHeight;
+        if (!hasRanges || !line.length) {
+            ctx.fillStyle = t.color;
+            ctx.fillText(line, t.x, y);
+        } else {
+            // walk the line in same-color runs so mixed-color text still
+            // renders with a single fillText call per run
+            var pos = 0, curX = t.x;
+            while (pos < line.length) {
+                var color = PS.textColorAt(t, offset + pos);
+                var pos2 = pos + 1;
+                while (pos2 < line.length && PS.textColorAt(t, offset + pos2) === color) { pos2++; }
+                var seg = line.slice(pos, pos2);
+                ctx.fillStyle = color;
+                ctx.fillText(seg, curX, y);
+                curX += ctx.measureText(seg).width;
+                pos = pos2;
+            }
+        }
+        offset += line.length + 1;
     }
     }
 };
 };
 
 
@@ -88,6 +158,45 @@ PS.textLayerBounds = function (layer) {
     return { x: t.x, y: t.y, w: w, h: lines.length * lineHeight };
     return { x: t.x, y: t.y, w: w, h: lines.length * lineHeight };
 };
 };
 
 
+// draws the active text edit's selection on the overlay canvas, using the
+// exact same font metrics as PS.renderTextLayer so the highlight can never
+// visually drift from the real (canvas-rendered) glyphs underneath — the
+// textarea's own native selection rendering is kept fully invisible (see
+// the ::selection rule in style.css) precisely because its internal line-box
+// metrics don't reliably line up with the canvas's top-baseline text
+PS.drawTextEditSelection = function (ctx) {
+    var te = PS.textEdit;
+    if (!te) { return; }
+    var ed = te.editorEl;
+    var start = ed.selectionStart, end = ed.selectionEnd;
+    if (start == null || start === end) { return; }
+    if (start > end) { var tmp = start; start = end; end = tmp; }
+
+    var t = te.layer.text;
+    var mctx = te.layer.canvas.getContext("2d");
+    mctx.font = PS.textFontString(t);
+    var lineHeight = Math.round(t.size * 1.25);
+    var lines = (t.content || "").split("\n");
+    var z = PS.zoom;
+
+    ctx.save();
+    ctx.fillStyle = "rgba(74, 144, 217, 0.45)";
+    var offset = 0;
+    for (var i = 0; i < lines.length; i++) {
+        var line = lines[i];
+        var lineStart = offset, lineEnd = offset + line.length;
+        offset = lineEnd + 1; // account for the stripped newline
+        var s = Math.max(start, lineStart), e = Math.min(end, lineEnd);
+        if (s < e) {
+            var preWidth = mctx.measureText(line.slice(0, s - lineStart)).width;
+            var selWidth = mctx.measureText(line.slice(s - lineStart, e - lineStart)).width;
+            var p = PS.docToOverlay(t.x + preWidth, t.y + i * lineHeight);
+            ctx.fillRect(p.x, p.y, selWidth * z, lineHeight * z);
+        }
+    }
+    ctx.restore();
+};
+
 /* ---------- inline text editing session ---------- */
 /* ---------- inline text editing session ---------- */
 
 
 PS.textEdit = null; // {layer, isNew, beforeText, editorEl}
 PS.textEdit = null; // {layer, isNew, beforeText, editorEl}
@@ -112,16 +221,18 @@ PS.startTextEditOnLayer = function (layer) {
         editorEl: ed
         editorEl: ed
     };
     };
 
 
-    // hide the layer's own rendering while editing
-    layer.canvas.getContext("2d").clearRect(0, 0, layer.canvas.width, layer.canvas.height);
-    PS.requestRender();
-
+    // the textarea's own glyphs stay invisible (see positionTextEditor); the
+    // layer's canvas keeps rendering underneath so color changes (incl.
+    // per-range ones) are visible live while still editing
     PS.positionTextEditor();
     PS.positionTextEditor();
     ed.focus();
     ed.focus();
     ed.select();
     ed.select();
 
 
     ed.addEventListener("input", function () {
     ed.addEventListener("input", function () {
         PS.autoSizeTextEditor();
         PS.autoSizeTextEditor();
+        layer.text.content = ed.value;
+        PS.renderTextLayer(layer);
+        PS.requestRender();
     });
     });
     ed.addEventListener("keydown", function (e) {
     ed.addEventListener("keydown", function (e) {
         e.stopPropagation();
         e.stopPropagation();
@@ -147,8 +258,10 @@ PS.positionTextEditor = function () {
     ed.style.font = (t.italic ? "italic " : "") + (t.bold ? "bold " : "") +
     ed.style.font = (t.italic ? "italic " : "") + (t.bold ? "bold " : "") +
         (t.size * PS.zoom) + "px \"" + t.font + "\"";
         (t.size * PS.zoom) + "px \"" + t.font + "\"";
     ed.style.lineHeight = Math.round(t.size * 1.25 * PS.zoom) + "px";
     ed.style.lineHeight = Math.round(t.size * 1.25 * PS.zoom) + "px";
-    ed.style.color = t.color;
-    ed.style.caretColor = t.color;
+    // glyphs stay invisible: the real (possibly multi-color) text renders on
+    // the layer's canvas underneath, so the caret/selection just overlay it
+    ed.style.color = "transparent";
+    ed.style.caretColor = "var(--accent)";
     PS.autoSizeTextEditor();
     PS.autoSizeTextEditor();
 };
 };
 
 
@@ -262,7 +375,8 @@ PS.registerTool("text", {
         PS.ui.checkbox(host, "Italic", o.italic, function (v) {
         PS.ui.checkbox(host, "Italic", o.italic, function (v) {
             o.italic = v; PS.savePrefsDebounced(); PS.applyTextOptionToEdit();
             o.italic = v; PS.savePrefsDebounced(); PS.applyTextOptionToEdit();
         });
         });
-        PS.ui.label(host, "Click canvas to add text. Ctrl+Enter commits, Esc cancels. Color = foreground.");
+        PS.ui.label(host, "Click canvas to add text. Ctrl+Enter commits, Esc cancels. " +
+            "While editing, pick a color to recolor the selection, or the whole text if none is selected.");
     },
     },
     onDown: function (pt) {
     onDown: function (pt) {
         if (PS.textEdit) {
         if (PS.textEdit) {
@@ -291,6 +405,7 @@ PS.registerTool("text", {
             font: o.font,
             font: o.font,
             size: o.size,
             size: o.size,
             color: PS.fg,
             color: PS.fg,
+            colorRanges: [],
             bold: o.bold,
             bold: o.bold,
             italic: o.italic,
             italic: o.italic,
             x: Math.round(pt.x),
             x: Math.round(pt.x),

+ 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: [] },
     gradient: { preset: "fg-bg", style: "linear", reverse: false, opacity: 1, stops: [] },
     wand: { tolerance: 32, contiguous: true, smart: true, edgeThreshold: 60 },
     wand: { tolerance: 32, contiguous: true, smart: true, edgeThreshold: 60 },
     marquee: { feather: 0 },
     marquee: { feather: 0 },
+    move: { showBounds: false },
     shape: { kind: "rect", mode: "both", strokeWidth: 6, radius: 12, points: 5 },
     shape: { kind: "rect", mode: "both", strokeWidth: 6, radius: 12, points: 5 },
     text: { font: "Arial", size: 48, bold: false, italic: false },
     text: { font: "Arial", size: 48, bold: false, italic: false },
     zoom: {}
     zoom: {}
@@ -304,7 +305,7 @@ PS.bindWorkspaceEvents = function () {
 
 
         // Update cursor for handle / guide hover (only when not mid-stroke/drag)
         // Update cursor for handle / guide hover (only when not mid-stroke/drag)
         if (!PS._pointer.down) {
         if (!PS._pointer.down) {
-            var tCursor = PS.selTransform.getCursor(PS.cursorPos);
+            var tCursor = PS.selTransform.getCursor(PS.cursorPos) || PS.moveBoundsCursor(PS.cursorPos);
             if (!tCursor) {
             if (!tCursor) {
                 var gh = PS.guideHitTest(raw);
                 var gh = PS.guideHitTest(raw);
                 if (gh) { tCursor = (gh.orient === "h") ? "row-resize" : "col-resize"; }
                 if (gh) { tCursor = (gh.orient === "h") ? "row-resize" : "col-resize"; }
@@ -638,6 +639,40 @@ PS.paintCursorOverlay = function (size, square) {
 /* ----- Move (V) ----- */
 /* ----- Move (V) ----- */
 (function () {
 (function () {
     var drag = null;
     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", {
     PS.registerTool("move", {
         name: "Move",
         name: "Move",
@@ -645,12 +680,36 @@ PS.paintCursorOverlay = function (size, square) {
         cursor: "move",
         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>',
         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) {
         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) {
         onDown: function (pt, e) {
             var layer = PS.activeLayer();
             var layer = PS.activeLayer();
             if (!layer) { return; }
             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") {
             if (layer.type === "text") {
                 drag = { mode: "text", layer: layer, start: pt, ox: layer.text.x, oy: layer.text.y };
                 drag = { mode: "text", layer: layer, start: pt, ox: layer.text.x, oy: layer.text.y };
                 return;
                 return;
@@ -744,6 +803,49 @@ PS.paintCursorOverlay = function (size, square) {
             }
             }
             drag = null;
             drag = null;
             PS.requestRender();
             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();
         }
         }
     });
     });
 
 

+ 1 - 1
src/web/login.html

@@ -268,7 +268,7 @@
                 <div class="ui breadcrumb" style="margin-top:12px;">
                 <div class="ui breadcrumb" style="margin-top:12px;">
                     <a class="section signup" style="cursor:pointer; display:none;" href="public/register/register.system" locale="login/signUp">Sign Up</a>
                     <a class="section signup" style="cursor:pointer; display:none;" href="public/register/register.system" locale="login/signUp">Sign Up</a>
                     <div class="divider signup"> / </div>
                     <div class="divider signup"> / </div>
-                    <a  id="forgetpw" class="section" style="cursor:pointer" href="reset.system" locale="login/forgotPassword">Forgot Password</a>
+                    <a  id="forgetpw" class="section" style="cursor:pointer" href="reset.html" locale="login/forgotPassword">Forgot Password</a>
                 </div>
                 </div>
                 <p style="margin-top:18px;color:#ff7a70; display:none;font-size:1.2em;"><i class="remove icon"></i><span id="errmsg" locale="login/errorIncorrect">Error. Incorrect username or password.</span></p>
                 <p style="margin-top:18px;color:#ff7a70; display:none;font-size:1.2em;"><i class="remove icon"></i><span id="errmsg" locale="login/errorIncorrect">Error. Incorrect username or password.</span></p>