/*
Pixel Studio - tool framework and built-in tools
Left toolbar, options bar, pointer event pipeline, brush engine,
shape drawing, selection tools, move / zoom / hand.
*/
"use strict";
/* ---------- default tool options (persisted in prefs) ---------- */
PS.toolOpts = {
brush: { size: 24, hardness: 0.8, opacity: 1, flow: 0.7, type: "round" },
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 },
move: { showBounds: false },
shape: { kind: "rect", mode: "both", strokeWidth: 6, radius: 12, points: 5 },
text: { font: "Arial", size: 48, bold: false, italic: false },
zoom: {}
};
/* ---------- toolbar grouping (fly-out submenus) ---------- */
// Toolbar entries: single tools, tool groups (one button + right-click
// fly-out), and the shape picker (fly-out chooses the shape kind visually).
PS.toolbarLayout = [
{ kind: "single", tool: "move" },
{ kind: "group", id: "select", tools: ["marquee-rect", "marquee-ellipse", "lasso", "lasso-poly"] },
{ kind: "single", tool: "wand" },
{ kind: "group", id: "paint", tools: ["brush", "pencil"] },
{ kind: "single", tool: "eraser" },
{ kind: "group", id: "bucket", tools: ["fill", "gradient"] },
{ kind: "single", tool: "eyedropper" },
{ kind: "single", tool: "text" },
{ kind: "shape" },
{ kind: "single", tool: "hand" },
{ kind: "single", tool: "zoom" }
];
// Last-selected member shown on each group's toolbar button.
PS.groupRep = { select: "marquee-rect", paint: "brush", bucket: "fill" };
// Per-shape-kind icons for the shape fly-out and toolbar button.
PS.shapeIcons = {
rect: '',
rounded: '',
ellipse: '',
line: '',
arrow: '',
triangle: '',
star: ''
};
/* ---------- framework ---------- */
PS.registerTool = function (id, def) {
def.id = id;
PS.tools[id] = def;
};
PS.setTool = function (id) {
if (!PS.tools[id]) { return; }
if (PS.commitTextEdit) { PS.commitTextEdit(); }
PS.closeToolFlyout();
var old = PS.tools[PS.tool];
if (old && old.deactivate) { old.deactivate(); }
PS.tool = id;
// remember this tool as its toolbar group's representative
PS.toolbarLayout.forEach(function (entry) {
if (entry.kind === "group" && entry.tools.indexOf(id) >= 0) {
PS.groupRep[entry.id] = id;
}
});
PS.renderToolbar();
PS.renderOptionsBar();
var ws = PS.el("workspace");
ws.style.cursor = PS.tools[id].cursor || "crosshair";
PS.savePrefsDebounced();
};
PS.renderToolbar = function () {
var host = PS.el("toolbar-buttons");
host.innerHTML = "";
PS.toolbarLayout.forEach(function (entry) {
if (entry.kind === "single") {
host.appendChild(PS._singleToolBtn(entry.tool));
} else if (entry.kind === "group") {
host.appendChild(PS._groupToolBtn(entry));
} else if (entry.kind === "shape") {
host.appendChild(PS._shapeToolBtn());
}
});
};
// build the base toolbar button (icon, active state, optional fly-out triangle)
PS._toolBtn = function (icon, title, active, hasFlyout) {
var btn = document.createElement("button");
btn.className = "tool-btn" + (active ? " active" : "");
btn.title = title;
btn.innerHTML = icon;
if (hasFlyout) {
var tri = document.createElement("span");
tri.className = "flyout-tri";
btn.appendChild(tri);
}
return btn;
};
PS._singleToolBtn = function (id) {
var def = PS.tools[id];
var btn = PS._toolBtn(def.icon,
def.name + (def.key ? " (" + def.key.toUpperCase() + ")" : ""),
PS.tool === id, false);
btn.addEventListener("click", function () { PS.setTool(id); });
return btn;
};
PS._groupToolBtn = function (entry) {
var inGroup = entry.tools.indexOf(PS.tool) >= 0;
var rep = inGroup ? PS.tool : PS.groupRep[entry.id];
if (entry.tools.indexOf(rep) < 0) { rep = entry.tools[0]; }
var def = PS.tools[rep];
var btn = PS._toolBtn(def.icon,
def.name + " — right-click for more", inGroup, true);
btn.addEventListener("click", function () { PS.setTool(rep); });
btn.addEventListener("contextmenu", function (e) {
e.preventDefault();
PS.openToolFlyout(btn, entry.tools.map(function (t) {
var d = PS.tools[t];
return {
icon: d.icon, label: d.name, active: PS.tool === t,
onSelect: function () { PS.setTool(t); }
};
}));
});
return btn;
};
PS._shapeToolBtn = function () {
var kind = PS.toolOpts.shape.kind;
var icon = PS.shapeIcons[kind] || PS.tools.shape.icon;
var btn = PS._toolBtn(icon, "Shape — right-click to pick a shape",
PS.tool === "shape", true);
btn.addEventListener("click", function () { PS.setTool("shape"); });
btn.addEventListener("contextmenu", function (e) {
e.preventDefault();
PS.openToolFlyout(btn, PS.shapeKinds.map(function (k) {
return {
icon: PS.shapeIcons[k.v] || PS.tools.shape.icon,
label: k.l,
active: PS.tool === "shape" && PS.toolOpts.shape.kind === k.v,
onSelect: function () {
PS.toolOpts.shape.kind = k.v;
PS.setTool("shape");
PS.savePrefsDebounced();
}
};
}));
});
return btn;
};
/* ---------- tool fly-out submenu ---------- */
PS._toolFlyout = null;
PS.openToolFlyout = function (btn, items) {
PS.closeToolFlyout();
var fly = document.createElement("div");
fly.className = "tool-flyout";
items.forEach(function (it) {
var row = document.createElement("div");
row.className = "tool-flyout-item" + (it.active ? " active" : "");
var ic = document.createElement("span");
ic.className = "tfi-icon";
ic.innerHTML = it.icon;
var lb = document.createElement("span");
lb.className = "tfi-label";
lb.textContent = it.label;
row.appendChild(ic);
row.appendChild(lb);
row.addEventListener("click", function (e) {
e.stopPropagation();
PS.closeToolFlyout();
it.onSelect();
});
fly.appendChild(row);
});
document.body.appendChild(fly);
// open to the right of the button, top edge aligned with the button top
var r = btn.getBoundingClientRect();
fly.style.left = Math.round(r.right + 2) + "px";
fly.style.top = Math.round(r.top) + "px";
var fr = fly.getBoundingClientRect();
if (fr.bottom > window.innerHeight - 4) {
fly.style.top = Math.max(4, window.innerHeight - fr.height - 4) + "px";
}
PS._toolFlyout = fly;
setTimeout(function () {
document.addEventListener("pointerdown", PS._flyoutOutside, true);
document.addEventListener("keydown", PS._flyoutKey, true);
}, 0);
};
PS.closeToolFlyout = function () {
if (PS._toolFlyout) { PS._toolFlyout.remove(); PS._toolFlyout = null; }
document.removeEventListener("pointerdown", PS._flyoutOutside, true);
document.removeEventListener("keydown", PS._flyoutKey, true);
};
PS._flyoutOutside = function (e) {
if (PS._toolFlyout && !PS._toolFlyout.contains(e.target)) { PS.closeToolFlyout(); }
};
PS._flyoutKey = function (e) {
if (e.key === "Escape") { PS.closeToolFlyout(); }
};
PS.renderOptionsBar = function () {
var host = PS.el("optionsbar");
host.innerHTML = "";
var def = PS.tools[PS.tool];
if (!def) { return; }
var name = document.createElement("span");
name.className = "tool-name";
name.textContent = def.name;
host.appendChild(name);
if (def.options) { def.options(host); }
};
/* ---------- pointer event pipeline ---------- */
PS._pointer = { down: false, panning: false, panStart: null };
PS.bindWorkspaceEvents = function () {
var ws = PS.el("workspace");
ws.addEventListener("contextmenu", function (e) { e.preventDefault(); });
ws.addEventListener("pointerdown", function (e) {
if (!PS.doc) { return; }
if (e.button === 2) { return; }
// ignore presses on the workspace scrollbars
var wsRect = ws.getBoundingClientRect();
if (e.clientX - wsRect.left > ws.clientWidth ||
e.clientY - wsRect.top > ws.clientHeight) {
return;
}
ws.setPointerCapture(e.pointerId);
PS._pointer.down = true;
if (e.button === 1 || PS.spacePan || PS.tool === "hand") {
PS._pointer.panning = true;
PS._pointer.panStart = {
x: e.clientX, y: e.clientY,
sl: ws.scrollLeft, st: ws.scrollTop
};
ws.style.cursor = "grabbing";
e.preventDefault();
return;
}
var raw = PS.eventToDoc(e);
var pt = PS.snapDocPoint(raw);
if (PS.selTransform.onDown(pt, e)) {
e.preventDefault();
return;
}
// grab an existing guide (Move tool) before handing off to the tool
if (PS.guideDragStart(raw)) {
e.preventDefault();
return;
}
var def = PS.tools[PS.tool];
if (def && def.onDown) { def.onDown(pt, e); }
e.preventDefault();
});
ws.addEventListener("pointermove", function (e) {
if (!PS.doc) { return; }
var raw = PS.eventToDoc(e);
PS.cursorPos = PS.snapDocPoint(raw);
PS.updateCursorStatus();
if (PS._pointer.panning) {
var p = PS._pointer.panStart;
ws.scrollLeft = p.sl - (e.clientX - p.x);
ws.scrollTop = p.st - (e.clientY - p.y);
return;
}
if (PS.guidesDragging()) {
PS.guideDragMove(raw);
return;
}
if (PS.selTransform.dragging) {
PS.selTransform.onMove(PS.cursorPos);
return;
}
// Update cursor for handle / guide hover (only when not mid-stroke/drag)
if (!PS._pointer.down) {
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"; }
}
ws.style.cursor = tCursor || (PS.tools[PS.tool] || {}).cursor || "crosshair";
}
var def = PS.tools[PS.tool];
if (def && def.onMove) { def.onMove(PS.cursorPos, e); }
});
function finish(e) {
if (!PS.doc) { return; }
if (PS._pointer.panning) {
PS._pointer.panning = false;
ws.style.cursor = (PS.tools[PS.tool] || {}).cursor || "crosshair";
} else if (PS.guidesDragging()) {
PS.guideDragEnd(PS.eventToDoc(e));
ws.style.cursor = (PS.tools[PS.tool] || {}).cursor || "crosshair";
} else if (PS.selTransform.dragging) {
PS.selTransform.onUp();
ws.style.cursor = (PS.tools[PS.tool] || {}).cursor || "crosshair";
} else if (PS._pointer.down) {
var def = PS.tools[PS.tool];
if (def && def.onUp) { def.onUp(PS.snapDocPoint(PS.eventToDoc(e)), e); }
}
PS._pointer.down = false;
}
ws.addEventListener("pointerup", finish);
ws.addEventListener("pointercancel", finish);
ws.addEventListener("dblclick", function (e) {
var def = PS.tools[PS.tool];
if (def && def.onDblClick) { def.onDblClick(PS.eventToDoc(e), e); }
});
// Ctrl+wheel zoom at pointer, plain wheel scrolls (default)
ws.addEventListener("wheel", function (e) {
if (!PS.doc) { return; }
if (e.ctrlKey) {
e.preventDefault();
var pt = PS.eventToDoc(e);
PS.setZoom(PS.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15), pt);
}
}, { passive: false });
ws.addEventListener("pointerleave", function () {
PS.cursorPos = null;
PS.updateCursorStatus();
});
};
/* ---------- shared helpers ---------- */
// selection combine mode from modifier keys
PS.selModeFromEvent = function (e) {
if (e.shiftKey && e.altKey) { return "intersect"; }
if (e.shiftKey) { return "add"; }
if (e.altKey) { return "subtract"; }
return "replace";
};
PS.requirePaintableLayer = function () {
var layer = PS.activeLayer();
if (!layer) { return null; }
if (layer.type === "text") {
PS.toast("Text layer: rasterize it first (Layer menu) to paint on it", true);
return null;
}
if (!layer.visible) {
PS.toast("Layer is hidden", true);
return null;
}
return layer;
};
PS.sampleColorAt = function (pt, comp, toBg) {
var x = Math.floor(pt.x), y = Math.floor(pt.y);
if (x < 0 || y < 0 || x >= PS.doc.width || y >= PS.doc.height) { return; }
var d = comp.getContext("2d").getImageData(x, y, 1, 1).data;
if (d[3] === 0) { return; }
var hex = PS.rgbToHex(d[0], d[1], d[2], d[3]);
if (toBg) { PS.setBg(hex); } else { PS.setFg(hex); }
};
// draw helper used by tool overlays: transform overlay ctx into doc space
PS.overlayDocSpace = function (ctx, fn) {
var origin = PS.docToOverlay(0, 0);
ctx.save();
ctx.translate(origin.x, origin.y);
ctx.scale(PS.zoom, PS.zoom);
fn(1 / PS.zoom); // pass screen-pixel size in doc units
ctx.restore();
};
/* ============================================================
BRUSH ENGINE (brush / pencil / eraser)
Stamps are accumulated at full opacity into a stroke buffer which
is composited onto the layer with the stroke opacity on release,
clipped by the selection. This gives Photoshop-style opacity
semantics (self-overlapping strokes do not darken).
============================================================ */
PS.strokeTypes = [
{ v: "round", l: "Round" },
{ v: "soft", l: "Soft Round" },
{ v: "calligraphy", l: "Calligraphy" },
{ v: "marker", l: "Marker" },
{ v: "spray", l: "Airbrush / Spray" }
];
PS._stroke = null;
PS.beginStroke = function (kind, pt, e) {
if (e.altKey && kind !== "eraser") {
PS.sampleColorAt(pt, PS.compositeToCanvas(), false);
return;
}
var layer = PS.requirePaintableLayer();
if (!layer) { return; }
var opts = PS.toolOpts[kind];
// Stamps are drawn opaque into the buffer; the foreground color's alpha is
// applied (with the stroke opacity) at composite time so self-overlap
// within a stroke does not darken.
var rgb = PS.hexToRgb(PS.fg) || { r: 0, g: 0, b: 0, a: 255 };
var colorAlpha = (kind === "eraser") ? 1 : (rgb.a === undefined ? 1 : rgb.a / 255);
PS._stroke = {
kind: kind,
layer: layer,
opts: opts,
color: (kind === "eraser") ? "#000000" : PS.rgbToHex(rgb.r, rgb.g, rgb.b),
colorAlpha: colorAlpha,
before: PS.snapshotLayer(layer),
canvas: PS.createCanvas(PS.doc.width, PS.doc.height),
last: pt,
rest: 0
};
PS._stroke.ctx = PS._stroke.canvas.getContext("2d");
PS.strokePreview = {
layer: layer,
canvas: PS._stroke.canvas,
opacity: opts.opacity * colorAlpha,
erase: kind === "eraser"
};
PS.stampSegment(pt, pt);
PS.requestRender();
};
PS.continueStroke = function (pt) {
if (!PS._stroke) { return; }
PS.stampSegment(PS._stroke.last, pt);
PS._stroke.last = pt;
PS.requestRender();
};
PS.endStroke = function () {
var s = PS._stroke;
if (!s) { return; }
var buf = s.canvas;
if (PS.doc.selection) {
var masked = PS.cloneCanvas(buf);
var mctx = masked.getContext("2d");
mctx.globalCompositeOperation = "destination-in";
mctx.drawImage(PS.doc.selection.mask, 0, 0);
buf = masked;
}
var ctx = s.layer.canvas.getContext("2d");
ctx.globalAlpha = s.opts.opacity * (s.colorAlpha === undefined ? 1 : s.colorAlpha);
ctx.globalCompositeOperation = (s.kind === "eraser") ? "destination-out" : "source-over";
ctx.drawImage(buf, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
PS.strokePreview = null;
PS._stroke = null;
var labels = { brush: "Brush Stroke", pencil: "Pencil", eraser: "Eraser" };
PS.commitLayerCanvas(labels[s.kind], s.layer, s.before);
PS.requestRender();
};
PS.stampSegment = function (from, to) {
var s = PS._stroke;
var opts = s.opts;
var ctx = s.ctx;
var size = opts.size;
var color = s.color;
if (s.kind === "pencil") {
// crisp pixel stamps along the line
ctx.fillStyle = color;
var dist = Math.hypot(to.x - from.x, to.y - from.y);
var steps = Math.max(1, Math.ceil(dist));
for (var i = 0; i <= steps; i++) {
var t = i / steps;
var x = from.x + (to.x - from.x) * t;
var y = from.y + (to.y - from.y) * t;
ctx.fillRect(Math.round(x - size / 2), Math.round(y - size / 2), size, size);
}
return;
}
var type = (s.kind === "eraser") ? opts.type : opts.type;
if (type === "round") {
// continuous segments give the smoothest hard-round result
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (from.x === to.x && from.y === to.y) {
ctx.beginPath();
ctx.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
return;
}
// spaced stamps for textured stroke types
var spacingByType = { soft: 0.15, calligraphy: 0.08, marker: 0.25, spray: 0.3 };
var spacing = Math.max(1, size * (spacingByType[type] || 0.2));
var dx = to.x - from.x, dy = to.y - from.y;
var dist2 = Math.hypot(dx, dy);
if (dist2 === 0) {
PS.stampAt(ctx, to.x, to.y, type, size, color, opts);
return;
}
var travelled = s.rest;
while (travelled <= dist2) {
var tt = travelled / dist2;
PS.stampAt(ctx, from.x + dx * tt, from.y + dy * tt, type, size, color, opts);
travelled += spacing;
}
s.rest = travelled - dist2;
};
PS.stampAt = function (ctx, x, y, type, size, color, opts) {
var r = size / 2;
var rgb = PS.hexToRgb(color) || { r: 0, g: 0, b: 0 };
if (type === "soft") {
var g = ctx.createRadialGradient(x, y, 0, x, y, r);
var solid = "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + ",1)";
var clear = "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + ",0)";
var hard = PS.clamp(opts.hardness !== undefined ? opts.hardness : 0.5, 0, 0.99);
g.addColorStop(0, solid);
g.addColorStop(hard, solid);
g.addColorStop(1, clear);
ctx.globalAlpha = opts.flow !== undefined ? opts.flow : 0.6;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
} else if (type === "calligraphy") {
ctx.save();
ctx.translate(x, y);
ctx.rotate(-Math.PI / 4);
ctx.scale(1, 0.3);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (type === "marker") {
ctx.globalAlpha = (opts.flow !== undefined ? opts.flow : 0.6) * 0.6;
ctx.fillStyle = color;
ctx.fillRect(x - r, y - r, size, size);
ctx.globalAlpha = 1;
} else if (type === "spray") {
ctx.fillStyle = color;
var dots = Math.max(6, Math.round(size * 0.8));
for (var i = 0; i < dots; i++) {
var ang = Math.random() * Math.PI * 2;
var rad = Math.sqrt(Math.random()) * r;
var dr = 0.5 + Math.random();
ctx.globalAlpha = 0.25;
ctx.beginPath();
ctx.arc(x + Math.cos(ang) * rad, y + Math.sin(ang) * rad, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
};
// circular size cursor for paint tools
PS.paintCursorOverlay = function (size, square) {
return function (ctx) {
if (!PS.cursorPos || PS._pointer.panning) { return; }
var p = PS.docToOverlay(PS.cursorPos.x, PS.cursorPos.y);
var r = (typeof size === "function" ? size() : size) * PS.zoom / 2;
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.lineWidth = 1;
ctx.beginPath();
if (square) {
ctx.rect(p.x - r, p.y - r, r * 2, r * 2);
} else {
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
}
ctx.stroke();
ctx.strokeStyle = "rgba(0,0,0,0.6)";
ctx.beginPath();
if (square) {
ctx.rect(p.x - r - 1, p.y - r - 1, r * 2 + 2, r * 2 + 2);
} else {
ctx.arc(p.x, p.y, r + 1, 0, Math.PI * 2);
}
ctx.stroke();
};
};
/* ============================================================
TOOL DEFINITIONS
============================================================ */
/* ----- 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",
key: "v",
cursor: "move",
icon: '',
options: function (host) {
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;
}
var sel = PS.doc.selection;
var inSel = sel && PS.pointInSelection(pt);
var base = PS.cloneCanvas(layer.canvas);
var float;
if (inSel) {
float = PS.getSelectedPixels(layer.canvas).canvas;
var bctx = base.getContext("2d");
bctx.globalCompositeOperation = "destination-out";
bctx.drawImage(sel.mask, 0, 0);
} else {
float = PS.cloneCanvas(layer.canvas);
base.getContext("2d").clearRect(0, 0, base.width, base.height);
}
drag = {
mode: "raster",
layer: layer,
start: pt,
before: PS.snapshotLayer(layer),
beforeSel: sel,
base: base,
float: float,
withSel: !!inSel,
preview: PS.createCanvas(PS.doc.width, PS.doc.height),
dx: 0, dy: 0
};
},
onMove: function (pt) {
if (!drag) { return; }
if (drag.mode === "text") {
drag.layer.text.x = drag.ox + (pt.x - drag.start.x);
drag.layer.text.y = drag.oy + (pt.y - drag.start.y);
PS.renderTextLayer(drag.layer);
PS.requestRender();
return;
}
drag.dx = Math.round(pt.x - drag.start.x);
drag.dy = Math.round(pt.y - drag.start.y);
var pctx = drag.preview.getContext("2d");
pctx.clearRect(0, 0, drag.preview.width, drag.preview.height);
pctx.drawImage(drag.base, 0, 0);
pctx.drawImage(drag.float, drag.dx, drag.dy);
PS.layerOverride = { layer: drag.layer, canvas: drag.preview };
PS.requestRender();
},
onUp: function () {
if (!drag) { return; }
if (drag.mode === "text") {
var layer = drag.layer, ox = drag.ox, oy = drag.oy;
var nx = layer.text.x, ny = layer.text.y;
if (nx !== ox || ny !== oy) {
PS.pushHistory("Move Text",
function () { layer.text.x = ox; layer.text.y = oy; PS.renderTextLayer(layer); },
function () { layer.text.x = nx; layer.text.y = ny; PS.renderTextLayer(layer); });
}
drag = null;
return;
}
PS.layerOverride = null;
if (drag.dx !== 0 || drag.dy !== 0) {
var lyr = drag.layer;
var ctx = lyr.canvas.getContext("2d");
ctx.clearRect(0, 0, lyr.canvas.width, lyr.canvas.height);
ctx.drawImage(drag.base, 0, 0);
ctx.drawImage(drag.float, drag.dx, drag.dy);
var beforeCanvas = drag.before;
var afterCanvas = PS.cloneCanvas(lyr.canvas);
var beforeSel = drag.beforeSel;
var afterSel = beforeSel;
if (drag.withSel) {
PS.translateSelection(drag.dx, drag.dy);
afterSel = PS.doc.selection;
}
PS.pushHistory("Move",
function () {
PS.restoreLayerCanvas(lyr, beforeCanvas);
PS.doc.selection = beforeSel;
},
function () {
PS.restoreLayerCanvas(lyr, afterCanvas);
PS.doc.selection = afterSel;
});
}
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();
}
});
// arrow-key nudge (called from hotkeys)
PS.nudgeMove = function (dx, dy) {
var layer = PS.activeLayer();
if (!layer) { return; }
if (layer.type === "text") {
layer.text.x += dx; layer.text.y += dy;
PS.renderTextLayer(layer);
PS.requestRender();
PS.markDirty();
return;
}
var before = PS.snapshotLayer(layer);
var moved = PS.createCanvas(PS.doc.width, PS.doc.height);
moved.getContext("2d").drawImage(layer.canvas, dx, dy);
var ctx = layer.canvas.getContext("2d");
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
ctx.drawImage(moved, 0, 0);
if (PS.doc.selection) { PS.translateSelection(dx, dy); }
PS.commitLayerCanvas("Nudge", layer, before);
PS.requestRender();
};
PS.pointInSelection = function (pt) {
var sel = PS.doc.selection;
if (!sel) { return false; }
var x = Math.floor(pt.x), y = Math.floor(pt.y);
if (x < 0 || y < 0 || x >= PS.doc.width || y >= PS.doc.height) { return false; }
var a = sel.mask.getContext("2d").getImageData(x, y, 1, 1).data[3];
return a >= 128;
};
})();
/* ----- Marquee selections (M) ----- */
(function () {
function makeMarquee(id, name, ellipse, icon) {
var drag = null;
PS.registerTool(id, {
name: name,
key: "m",
group: "marquee",
cursor: "crosshair",
icon: icon,
options: function (host) {
PS.ui.slider(host, "Feather", PS.toolOpts.marquee.feather, 0, 50, 1, function (v) {
PS.toolOpts.marquee.feather = v;
PS.savePrefsDebounced();
}, function (v) { return v + "px"; });
PS.ui.label(host, "Shift adds, Alt subtracts from a selection");
},
onDown: function (pt, e) {
drag = { start: pt, cur: pt, mode: PS.selModeFromEvent(e), constrain: false };
},
onMove: function (pt, e) {
if (!drag) { return; }
drag.cur = pt;
drag.constrain = e.shiftKey && drag.mode === "replace";
},
onUp: function (pt) {
if (!drag) { return; }
var r = normRect(drag.start, drag.cur, drag.constrain);
var mode = drag.mode;
drag = null;
if (r.w < 2 && r.h < 2) {
if (mode === "replace") { PS.deselect(); }
return;
}
var mask = PS.maskFromRect(r.x, r.y, r.w, r.h, ellipse);
var feather = PS.toolOpts.marquee.feather;
if (feather > 0) {
var soft = PS.makeMaskCanvas();
var sctx = soft.getContext("2d");
sctx.filter = "blur(" + feather + "px)";
sctx.drawImage(mask, 0, 0);
sctx.filter = "none";
mask = soft;
}
PS.setSelection(mask, mode, name);
},
overlay: function (ctx) {
if (!drag) { return; }
var r = normRect(drag.start, drag.cur, drag.constrain);
PS.overlayDocSpace(ctx, function (px) {
ctx.lineWidth = px;
ctx.setLineDash([4 * px, 4 * px]);
ctx.strokeStyle = "#fff";
ctx.beginPath();
if (ellipse) {
ctx.ellipse(r.x + r.w / 2, r.y + r.h / 2, r.w / 2, r.h / 2, 0, 0, Math.PI * 2);
} else {
ctx.rect(r.x, r.y, r.w, r.h);
}
ctx.stroke();
ctx.strokeStyle = "#000";
ctx.lineDashOffset = 4 * px;
ctx.stroke();
});
}
});
}
function normRect(a, b, constrain) {
var w = b.x - a.x, h = b.y - a.y;
if (constrain) {
var m = Math.max(Math.abs(w), Math.abs(h));
w = (w < 0 ? -m : m);
h = (h < 0 ? -m : m);
}
return {
x: Math.min(a.x, a.x + w),
y: Math.min(a.y, a.y + h),
w: Math.abs(w),
h: Math.abs(h)
};
}
makeMarquee("marquee-rect", "Rectangular Marquee", false,
'');
makeMarquee("marquee-ellipse", "Elliptical Marquee", true,
'');
})();
/* ----- Lasso tools (L) ----- */
(function () {
// freehand lasso
var path = null;
PS.registerTool("lasso", {
name: "Lasso",
key: "l",
group: "lasso",
cursor: "crosshair",
icon: '',
options: function (host) {
PS.ui.label(host, "Drag a freehand selection. Shift adds, Alt subtracts.");
},
onDown: function (pt, e) {
path = { points: [pt], mode: PS.selModeFromEvent(e) };
},
onMove: function (pt) {
if (!path) { return; }
var last = path.points[path.points.length - 1];
if (Math.hypot(pt.x - last.x, pt.y - last.y) >= 1.5) {
path.points.push(pt);
}
},
onUp: function () {
if (!path) { return; }
var pts = path.points, mode = path.mode;
path = null;
if (pts.length < 3) {
if (mode === "replace") { PS.deselect(); }
return;
}
PS.setSelection(PS.maskFromPolygon(pts), mode, "Lasso");
},
overlay: function (ctx) {
if (!path || path.points.length < 2) { return; }
drawPolyOverlay(ctx, path.points, false);
}
});
// polygonal lasso
var poly = null;
PS.registerTool("lasso-poly", {
name: "Polygonal Lasso",
key: "l",
group: "lasso",
cursor: "crosshair",
icon: '',
options: function (host) {
PS.ui.label(host, "Click to add points; double-click, Enter, or click the first point to close. Esc cancels.");
},
onDown: function (pt, e) {
if (!poly) {
poly = { points: [pt], mode: PS.selModeFromEvent(e) };
return;
}
// close if clicking near the starting point
var first = poly.points[0];
if (Math.hypot(pt.x - first.x, pt.y - first.y) * PS.zoom < 9 && poly.points.length >= 3) {
PS.finishPolyLasso();
return;
}
poly.points.push(pt);
},
onDblClick: function () { PS.finishPolyLasso(); },
onKey: function (e) {
if (e.key === "Enter") { PS.finishPolyLasso(); return true; }
if (e.key === "Escape") { poly = null; return true; }
return false;
},
deactivate: function () { poly = null; },
overlay: function (ctx) {
if (!poly) { return; }
var pts = poly.points.slice();
if (PS.cursorPos) { pts.push(PS.cursorPos); }
drawPolyOverlay(ctx, pts, true);
}
});
PS.finishPolyLasso = function () {
if (!poly || poly.points.length < 3) { poly = null; return; }
var pts = poly.points, mode = poly.mode;
poly = null;
PS.setSelection(PS.maskFromPolygon(pts), mode, "Polygonal Lasso");
};
function drawPolyOverlay(ctx, pts, markStart) {
PS.overlayDocSpace(ctx, function (px) {
ctx.lineWidth = px;
ctx.strokeStyle = "#fff";
ctx.setLineDash([4 * px, 4 * px]);
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) { ctx.lineTo(pts[i].x, pts[i].y); }
ctx.stroke();
ctx.strokeStyle = "#000";
ctx.lineDashOffset = 4 * px;
ctx.stroke();
if (markStart) {
ctx.setLineDash([]);
ctx.fillStyle = "#fff";
ctx.fillRect(pts[0].x - 3 * px, pts[0].y - 3 * px, 6 * px, 6 * px);
}
});
}
})();
/* ----- Magic wand / smart select (W) ----- */
PS.registerTool("wand", {
name: "Magic Wand",
key: "w",
cursor: "crosshair",
icon: '',
options: function (host) {
var o = PS.toolOpts.wand;
PS.ui.slider(host, "Tolerance", o.tolerance, 0, 150, 1, function (v) {
o.tolerance = v; PS.savePrefsDebounced();
});
PS.ui.checkbox(host, "Contiguous", o.contiguous, function (v) {
o.contiguous = v; PS.savePrefsDebounced();
});
PS.ui.sep(host);
PS.ui.checkbox(host, "Smart edges (edge detection)", o.smart, function (v) {
o.smart = v; PS.savePrefsDebounced();
});
PS.ui.slider(host, "Edge sensitivity", o.edgeThreshold, 10, 200, 1, function (v) {
o.edgeThreshold = v; PS.savePrefsDebounced();
});
},
onDown: function (pt, e) {
var o = PS.toolOpts.wand;
var mask = PS.magicWandMask(pt.x, pt.y, {
tolerance: o.tolerance,
contiguous: o.contiguous,
smart: o.smart,
edgeThreshold: o.edgeThreshold
});
if (!mask) { return; }
PS.setSelection(mask, PS.selModeFromEvent(e), o.smart ? "Smart Select" : "Magic Wand");
}
});
/* ----- Brush (B), Pencil, Eraser (E) ----- */
(function () {
function paintToolOptions(kind, host) {
var o = PS.toolOpts[kind];
PS.ui.slider(host, "Size", o.size, 1, 300, 1, function (v) {
o.size = v; PS.savePrefsDebounced();
}, function (v) { return v + "px"; });
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 + "%"; });
if (kind === "brush") {
PS.ui.select(host, "Stroke", PS.strokeTypes, o.type, function (v) {
o.type = v; PS.savePrefsDebounced();
});
PS.ui.slider(host, "Hardness", Math.round((o.hardness || 0.8) * 100), 0, 99, 1, function (v) {
o.hardness = v / 100; PS.savePrefsDebounced();
}, function (v) { return v + "%"; });
PS.ui.slider(host, "Flow", Math.round((o.flow || 0.7) * 100), 5, 100, 1, function (v) {
o.flow = v / 100; PS.savePrefsDebounced();
}, function (v) { return v + "%"; });
}
if (kind === "eraser") {
PS.ui.select(host, "Type", [
{ v: "round", l: "Hard Round" },
{ v: "soft", l: "Soft Round" }
], o.type, function (v) { o.type = v; PS.savePrefsDebounced(); });
}
PS.ui.label(host, "[ and ] change size" + (kind !== "eraser" ? ", Alt-click samples color" : ""));
}
function registerPaintTool(id, name, key, icon) {
PS.registerTool(id, {
name: name,
key: key,
group: (id === "eraser") ? undefined : "paint",
cursor: "crosshair",
icon: icon,
options: function (host) { paintToolOptions(id, host); },
onDown: function (pt, e) { PS.beginStroke(id, pt, e); },
onMove: function (pt) { PS.continueStroke(pt); },
onUp: function () { PS.endStroke(); },
overlay: PS.paintCursorOverlay(function () { return PS.toolOpts[id].size; }, id === "pencil")
});
}
registerPaintTool("brush", "Brush", "b",
'');
registerPaintTool("pencil", "Pencil", "b",
'');
registerPaintTool("eraser", "Eraser", "e",
'');
})();
/* ----- Paint bucket / fill (G) ----- */
PS.registerTool("fill", {
name: "Paint Bucket",
key: "g",
cursor: "crosshair",
icon: '',
options: function (host) {
var o = PS.toolOpts.fill;
PS.ui.slider(host, "Tolerance", o.tolerance, 0, 150, 1, function (v) {
o.tolerance = v; PS.savePrefsDebounced();
});
PS.ui.checkbox(host, "Contiguous", o.contiguous, function (v) {
o.contiguous = v; PS.savePrefsDebounced();
});
PS.ui.label(host, "Alt-click samples color");
},
onDown: function (pt, e) {
if (e.altKey) {
PS.sampleColorAt(pt, PS.compositeToCanvas(), false);
return;
}
var layer = PS.requirePaintableLayer();
if (!layer) { return; }
var before = PS.snapshotLayer(layer);
if (PS.floodFillLayer(layer, pt, PS.fg, PS.toolOpts.fill)) {
PS.commitLayerCanvas("Paint Bucket", layer, before);
PS.requestRender();
}
}
});
// flood fill on the layer's own pixels, honoring the selection mask
PS.floodFillLayer = function (layer, pt, hex, opts) {
var d = PS.doc;
var w = d.width, h = d.height;
var x = Math.floor(pt.x), y = Math.floor(pt.y);
if (x < 0 || y < 0 || x >= w || y >= h) { return false; }
var ctx = layer.canvas.getContext("2d");
var img = ctx.getImageData(0, 0, w, h);
var px = img.data;
var maskData = null;
if (d.selection) {
maskData = d.selection.mask.getContext("2d").getImageData(0, 0, w, h).data;
if (maskData[(y * w + x) * 4 + 3] < 128) { return false; }
}
var rgb = PS.hexToRgb(hex);
var i0 = (y * w + x) * 4;
var sr = px[i0], sg = px[i0 + 1], sb = px[i0 + 2], sa = px[i0 + 3];
var tol = opts.tolerance;
if (sr === rgb.r && sg === rgb.g && sb === rgb.b && sa === 255 && tol < 255) {
return false; // already that color
}
function matches(i) {
if (maskData && maskData[i + 3] < 128) { return false; }
return Math.abs(px[i] - sr) <= tol && Math.abs(px[i + 1] - sg) <= tol &&
Math.abs(px[i + 2] - sb) <= tol && Math.abs(px[i + 3] - sa) <= tol;
}
var fillA = (rgb.a === undefined) ? 255 : rgb.a;
function paint(i) {
px[i] = rgb.r; px[i + 1] = rgb.g; px[i + 2] = rgb.b; px[i + 3] = fillA;
}
var visited = new Uint8Array(w * h);
if (!opts.contiguous) {
for (var p = 0; p < w * h; p++) {
if (matches(p * 4)) { paint(p * 4); }
}
} else {
var stack = [[x, y]];
visited[y * w + x] = 1;
while (stack.length) {
var cur = stack.pop();
var cx = cur[0], cy = cur[1];
var left = cx;
while (left > 0 && !visited[cy * w + left - 1] && matches((cy * w + left - 1) * 4)) {
left--; visited[cy * w + left] = 1;
}
var right = cx;
while (right < w - 1 && !visited[cy * w + right + 1] && matches((cy * w + right + 1) * 4)) {
right++; visited[cy * w + right] = 1;
}
for (var sx = left; sx <= right; sx++) {
paint((cy * w + sx) * 4);
if (cy > 0 && !visited[(cy - 1) * w + sx] && matches(((cy - 1) * w + sx) * 4)) {
visited[(cy - 1) * w + sx] = 1;
stack.push([sx, cy - 1]);
}
if (cy < h - 1 && !visited[(cy + 1) * w + sx] && matches(((cy + 1) * w + sx) * 4)) {
visited[(cy + 1) * w + sx] = 1;
stack.push([sx, cy + 1]);
}
}
}
}
ctx.putImageData(img, 0, 0);
return true;
};
/* ----- Eyedropper (I) ----- */
(function () {
var sampling = null;
PS.registerTool("eyedropper", {
name: "Eyedropper",
key: "i",
cursor: "crosshair",
icon: '',
options: function (host) {
PS.ui.label(host, "Click to set foreground color, Alt-click to set background color");
},
onDown: function (pt, e) {
sampling = { comp: PS.compositeToCanvas(), toBg: e.altKey };
PS.sampleColorAt(pt, sampling.comp, sampling.toBg);
},
onMove: function (pt) {
if (sampling) { PS.sampleColorAt(pt, sampling.comp, sampling.toBg); }
},
onUp: function () { sampling = null; }
});
})();
/* ----- Shape tool (U) ----- */
(function () {
var drag = null;
PS.shapeKinds = [
{ v: "rect", l: "Rectangle" },
{ v: "rounded", l: "Rounded Rectangle" },
{ v: "ellipse", l: "Ellipse" },
{ v: "line", l: "Line" },
{ v: "arrow", l: "Arrow" },
{ v: "triangle", l: "Triangle" },
{ v: "star", l: "Star" }
];
PS.buildShapePath = function (ctx, kind, r, opts) {
ctx.beginPath();
if (kind === "rect") {
ctx.rect(r.x, r.y, r.w, r.h);
} else if (kind === "rounded") {
var rad = Math.min(opts.radius, r.w / 2, r.h / 2);
ctx.moveTo(r.x + rad, r.y);
ctx.arcTo(r.x + r.w, r.y, r.x + r.w, r.y + r.h, rad);
ctx.arcTo(r.x + r.w, r.y + r.h, r.x, r.y + r.h, rad);
ctx.arcTo(r.x, r.y + r.h, r.x, r.y, rad);
ctx.arcTo(r.x, r.y, r.x + r.w, r.y, rad);
ctx.closePath();
} else if (kind === "ellipse") {
ctx.ellipse(r.x + r.w / 2, r.y + r.h / 2, r.w / 2, r.h / 2, 0, 0, Math.PI * 2);
} else if (kind === "line") {
ctx.moveTo(r.x0, r.y0);
ctx.lineTo(r.x1, r.y1);
} else if (kind === "arrow") {
var ang = Math.atan2(r.y1 - r.y0, r.x1 - r.x0);
var len = Math.hypot(r.x1 - r.x0, r.y1 - r.y0);
var head = Math.min(len * 0.35, Math.max(12, opts.strokeWidth * 3));
ctx.moveTo(r.x0, r.y0);
ctx.lineTo(r.x1, r.y1);
ctx.moveTo(r.x1, r.y1);
ctx.lineTo(r.x1 - head * Math.cos(ang - 0.45), r.y1 - head * Math.sin(ang - 0.45));
ctx.moveTo(r.x1, r.y1);
ctx.lineTo(r.x1 - head * Math.cos(ang + 0.45), r.y1 - head * Math.sin(ang + 0.45));
} else if (kind === "triangle") {
ctx.moveTo(r.x + r.w / 2, r.y);
ctx.lineTo(r.x + r.w, r.y + r.h);
ctx.lineTo(r.x, r.y + r.h);
ctx.closePath();
} else if (kind === "star") {
var n = PS.clamp(opts.points || 5, 3, 12);
var cx = r.x + r.w / 2, cy = r.y + r.h / 2;
var R = Math.min(r.w, r.h) / 2;
var rr = R * 0.45;
for (var i = 0; i < n * 2; i++) {
var rad2 = (i % 2 === 0) ? R : rr;
var a = -Math.PI / 2 + i * Math.PI / n;
var X = cx + rad2 * Math.cos(a), Y = cy + rad2 * Math.sin(a);
if (i === 0) { ctx.moveTo(X, Y); } else { ctx.lineTo(X, Y); }
}
ctx.closePath();
}
};
function geom(drag) {
var a = drag.start, b = drag.cur;
var w = b.x - a.x, h = b.y - a.y;
if (drag.constrain) {
var kind = PS.toolOpts.shape.kind;
if (kind === "line" || kind === "arrow") {
// snap to 45 degree increments
var ang = Math.atan2(h, w);
var len = Math.hypot(w, h);
var snap = Math.round(ang / (Math.PI / 4)) * (Math.PI / 4);
w = len * Math.cos(snap);
h = len * Math.sin(snap);
} else {
var m = Math.max(Math.abs(w), Math.abs(h));
w = w < 0 ? -m : m;
h = h < 0 ? -m : m;
}
}
return {
x: Math.min(a.x, a.x + w), y: Math.min(a.y, a.y + h),
w: Math.abs(w), h: Math.abs(h),
x0: a.x, y0: a.y, x1: a.x + w, y1: a.y + h
};
}
function renderShape(ctx, r) {
var o = PS.toolOpts.shape;
PS.buildShapePath(ctx, o.kind, r, o);
var lineOnly = (o.kind === "line" || o.kind === "arrow");
if (!lineOnly && (o.mode === "fill" || o.mode === "both")) {
ctx.fillStyle = PS.fg;
ctx.fill();
}
if (lineOnly || o.mode === "stroke" || o.mode === "both") {
ctx.strokeStyle = lineOnly ? PS.fg : (o.mode === "both" ? PS.bg : PS.fg);
ctx.lineWidth = o.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
}
}
PS.registerTool("shape", {
name: "Shape",
key: "u",
cursor: "crosshair",
icon: '',
options: function (host) {
var o = PS.toolOpts.shape;
var kindLabel = o.kind;
PS.shapeKinds.forEach(function (k) { if (k.v === o.kind) { kindLabel = k.l; } });
PS.ui.label(host, "Shape: " + kindLabel + " (right-click the Shape tool to change)");
if (o.kind !== "line" && o.kind !== "arrow") {
PS.ui.select(host, "Mode", [
{ v: "fill", l: "Fill (FG)" },
{ v: "stroke", l: "Stroke (FG)" },
{ v: "both", l: "Fill FG + Stroke BG" }
], o.mode, function (v) { o.mode = v; PS.savePrefsDebounced(); });
}
PS.ui.slider(host, "Stroke width", o.strokeWidth, 1, 60, 1, function (v) {
o.strokeWidth = v; PS.savePrefsDebounced();
}, function (v) { return v + "px"; });
if (o.kind === "rounded") {
PS.ui.slider(host, "Corner radius", o.radius, 1, 100, 1, function (v) {
o.radius = v; PS.savePrefsDebounced();
});
}
if (o.kind === "star") {
PS.ui.slider(host, "Points", o.points, 3, 12, 1, function (v) {
o.points = v; PS.savePrefsDebounced();
});
}
PS.ui.label(host, "Shift constrains proportions");
},
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 r = geom(drag);
drag = null;
if (r.w < 2 && r.h < 2) { return; }
var layer = PS.requirePaintableLayer();
if (!layer) { return; }
var before = PS.snapshotLayer(layer);
PS.maskedDraw(layer, function (ctx) { renderShape(ctx, r); });
PS.commitLayerCanvas("Shape: " + PS.toolOpts.shape.kind, layer, before);
PS.requestRender();
},
overlay: function (ctx) {
if (!drag) { return; }
var r = geom(drag);
PS.overlayDocSpace(ctx, function () {
ctx.globalAlpha = 0.8;
renderShape(ctx, r);
ctx.globalAlpha = 1;
});
}
});
})();
/* ----- Hand (H) and Zoom (Z) ----- */
PS.registerTool("hand", {
name: "Hand",
key: "h",
cursor: "grab",
icon: '',
options: function (host) {
PS.ui.label(host, "Drag to pan. Hold Space with any tool for temporary pan.");
}
// panning handled by the pointer pipeline
});
(function () {
var drag = null;
PS.registerTool("zoom", {
name: "Zoom",
key: "z",
cursor: "zoom-in",
icon: '',
options: function (host) {
PS.ui.label(host, "Click to zoom in, Alt-click to zoom out, drag a box to zoom to area");
PS.ui.button(host, "Fit", function () { PS.zoomFit(); });
PS.ui.button(host, "100%", function () { PS.zoomActual(); });
},
onDown: function (pt) { drag = { start: pt, cur: pt }; },
onMove: function (pt) { if (drag) { drag.cur = pt; } },
onUp: function (pt, e) {
if (!drag) { return; }
var r = {
x: Math.min(drag.start.x, drag.cur.x),
y: Math.min(drag.start.y, drag.cur.y),
w: Math.abs(drag.cur.x - drag.start.x),
h: Math.abs(drag.cur.y - drag.start.y)
};
drag = null;
if (r.w * PS.zoom > 12 && r.h * PS.zoom > 12) {
var holder = PS.el("workspace-holder");
var z = Math.min((holder.clientWidth - 40) / r.w, (holder.clientHeight - 40) / r.h);
PS.setZoom(z, { x: r.x + r.w / 2, y: r.y + r.h / 2 });
} else {
PS.setZoom(PS.zoom * (e.altKey ? 1 / 1.5 : 1.5), pt);
}
},
overlay: function (ctx) {
if (!drag) { return; }
PS.overlayDocSpace(ctx, function (px) {
ctx.lineWidth = px;
ctx.strokeStyle = "#4a90d9";
ctx.setLineDash([4 * px, 3 * px]);
ctx.strokeRect(
Math.min(drag.start.x, drag.cur.x), Math.min(drag.start.y, drag.cur.y),
Math.abs(drag.cur.x - drag.start.x), Math.abs(drag.cur.y - drag.start.y));
});
}
});
})();