Files
awesome-copilot/extensions/draw/public/pointer.js
T

497 lines
18 KiB
JavaScript

// Pointer input: select, move, resize, connect, create, draw, pan and zoom.
import { isShape, snap, clamp, newId, idPrefix, normalizeElement, DEFAULT_SIZES, SHAPE_TYPES, MAX_SIDE, MAX_PEN_POINTS } from "/lib/model.mjs";
import {
hitTest, shapeAt, pointInShape, elementBounds, unionBounds, boxContains, arrowGeometry, lineHeight, fontSizeOf, fitStroke,
} from "/lib/geometry.mjs";
import { translateElement } from "./editor.js";
const DRAG = 3;
const DOUBLE_MS = 400;
const r2 = (v) => Math.round(v * 100) / 100;
function snapAxis(pos, size, cands, th) {
let best = null;
for (const off of [0, size / 2, size]) {
for (const c of cands) {
const d = c.v - (pos + off);
if (Math.abs(d) <= th && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, v: c.v };
}
}
return best;
}
function guideLine(axis, v, box, cands) {
const all = unionBounds([box, ...cands.filter((c) => Math.abs(c.v - v) < 0.5).map((c) => c.b)]);
return axis === "x"
? { x1: v, y1: all.y - 8, x2: v, y2: all.y + all.h + 8 }
: { x1: all.x - 8, y1: v, x2: all.x + all.w + 8, y2: v };
}
export function attachPointer(ed) {
const svg = ed.svg;
let it = null;
let lastClick = null;
let cursor = "";
const local = (e) => {
const r = ed.stage.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
const tol = () => 4 / ed.view.zoom;
const grid = (v, e) => (e.altKey ? r2(v) : snap(v));
function setCursor(c) {
if (c !== cursor) svg.style.cursor = cursor = c;
}
function cursorFor(h, hit) {
if (it) {
if (it.kind === "pan") return "grabbing";
if (it.kind === "move") return "move";
if (it.kind === "resize") return it.cursor;
if (it.kind === "marquee" || it.kind === "dbl") return "default";
return it.kind === "text" ? "text" : "crosshair";
}
if (ed.spaceDown || ed.tool === "hand") return "grab";
if (ed.tool === "text") return "text";
if (ed.tool !== "select") return "crosshair";
if (h) {
if (h.kind === "resize") return h.which === "nw" || h.which === "se" ? "nwse-resize" : "nesw-resize";
return h.kind === "connect" ? "pointer" : "grab";
}
return hit ? "move" : "default";
}
function hoverAt(s, p) {
if (ed.tool !== "select" || ed.editing || ed.spaceDown) {
setHover(null, null);
setCursor(cursorFor(null, null));
return;
}
const h = ed.handleAt(s.x, s.y);
let hover = null;
let hit = null;
if (h) {
hover = h.kind === "connect" ? h.id : ed.hover;
} else {
hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure);
if (hit && isShape(hit)) hover = hit.id;
else if (!hit && ed.hover) {
const cur = ed.byId.get(ed.hover);
if (cur && pointInShape(cur, p.x, p.y, 34 / ed.view.zoom)) hover = cur.id;
}
}
setHover(hover, h ? `${h.kind}:${h.id}:${h.which}` : null);
setCursor(cursorFor(h, hit));
}
function setHover(id, handle) {
if (id === ed.hover && handle === ed.hoverHandle) return;
ed.hover = id;
ed.hoverHandle = handle;
ed.requestRender();
}
ed.refreshCursor = () => setCursor(cursorFor(null, null));
function begin(next, e) {
it = next;
try {
svg.setPointerCapture(e.pointerId);
} catch {
// The pointer may already be gone.
}
setHover(it.kind === "connect" ? ed.hover : null, null);
ed.beginInteraction(it);
setCursor(cursorFor(null, null));
}
function down(e) {
if (it || (e.button !== 0 && e.button !== 1)) return;
e.preventDefault();
if (ed.editing) ed.labels.commit({ refocus: false });
ed.focusCanvas();
const s = local(e);
const p = ed.toWorld(e.clientX, e.clientY);
ed.lastPointer = p;
const base = { pointerId: e.pointerId, sx: s.x, sy: s.y, start: p, moved: false };
if (e.button === 1 || ed.spaceDown || ed.tool === "hand") return begin({ ...base, kind: "pan", view: { ...ed.view } }, e);
const now = performance.now();
const dbl = !!lastClick && now - lastClick.t < DOUBLE_MS && Math.hypot(s.x - lastClick.x, s.y - lastClick.y) < 6;
lastClick = dbl ? null : { t: now, x: s.x, y: s.y };
const tool = ed.tool;
if (tool === "select") {
const h = ed.handleAt(s.x, s.y);
if (h) {
lastClick = null;
return beginHandle(h, base, e);
}
const hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure);
if (dbl) return begin({ ...base, kind: "dbl", hit: hit ? hit.id : null }, e);
if (hit) {
if (e.shiftKey) {
ed.select([hit.id], { toggle: true });
if (!ed.selection.has(hit.id)) return;
} else if (!ed.selection.has(hit.id)) {
ed.select([hit.id]);
}
return begin({ ...base, kind: "move", hit: hit.id, shift: e.shiftKey }, e);
}
if (!e.shiftKey) ed.select([]);
return begin({ ...base, kind: "marquee", baseSel: e.shiftKey ? [...ed.selection] : [] }, e);
}
if (tool === "text") return begin({ ...base, kind: "text" }, e);
if (tool === "pen") {
begin({ ...base, kind: "pen", points: [[r2(p.x), r2(p.y)]] }, e);
ed.preview = { points: it.points, color: ed.styles.pen.color, width: ed.styles.pen.width };
return;
}
if (tool === "arrow") {
const from = e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol());
return begin({ ...base, kind: "arrow", from: from ? from.id : null, x1: grid(p.x, e), y1: grid(p.y, e) }, e);
}
if (SHAPE_TYPES.includes(tool)) return begin({ ...base, kind: "create", type: tool }, e);
}
function beginHandle(h, base, e) {
const el = ed.byId.get(h.id);
if (!el) return;
if (h.kind === "resize") {
const cursorName = h.which === "nw" || h.which === "se" ? "nwse-resize" : "nesw-resize";
return begin({ ...base, kind: "resize", which: h.which, orig: el, cursor: cursorName }, e);
}
if (h.kind === "end") return begin({ ...base, kind: "endpoint", which: h.which, orig: el }, e);
return begin({ ...base, kind: "connect", src: el.id, dir: h.which, arrowId: null }, e);
}
function move(e) {
const s = local(e);
const p = ed.toWorld(e.clientX, e.clientY);
ed.lastPointer = p;
if (!it) return hoverAt(s, p);
if (e.pointerId !== it.pointerId) return;
if (it.kind === "pen") return drawPen(e);
if (!it.moved && Math.hypot(s.x - it.sx, s.y - it.sy) < DRAG) return;
it.moved = true;
if (it.kind === "pan") {
const z = it.view.zoom;
ed.setView({ zoom: z, x: it.view.x - (s.x - it.sx) / z, y: it.view.y - (s.y - it.sy) / z });
return;
}
const fn = { move: dragMove, marquee: dragMarquee, resize: dragResize, endpoint: dragEndpoint, connect: dragConnect, arrow: dragArrow, create: dragCreate }[it.kind];
if (fn) fn(p, e);
ed.requestRender();
}
// How far a pen stroke may stray from what was drawn when it is simplified.
const penTolerance = () => 0.6 / ed.view.zoom;
function drawPen(e) {
const events = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
const min = 0.75 / ed.view.zoom;
for (const ev of events.length ? events : [e]) {
const q = ed.toWorld(ev.clientX, ev.clientY);
const last = it.points[it.points.length - 1];
if (Math.hypot(q.x - last[0], q.y - last[1]) >= min) it.points.push([r2(q.x), r2(q.y)]);
}
// A long stroke is simplified as it grows, so it never holds much more than a saved one can.
if (it.points.length >= 2 * MAX_PEN_POINTS) {
it.points = fitStroke(it.points, penTolerance());
if (ed.preview) ed.preview.points = it.points;
}
it.moved = true;
ed.requestRender();
}
function dragMove(p, e) {
if (!it.orig) {
it.orig = ed.elements;
it.ids = new Set(ed.selection);
const moving = it.orig.filter((el) => it.ids.has(el.id));
const boxes = (list) => list.map((el) => elementBounds(el, ed.byId, ed.measure));
it.box = unionBounds(boxes(moving.filter((el) => el.type !== "arrow"))) || unionBounds(boxes(moving));
it.xs = [];
it.ys = [];
for (const el of it.orig) {
if (it.ids.has(el.id) || !(isShape(el) || el.type === "text")) continue;
const b = elementBounds(el, ed.byId, ed.measure);
for (const v of [b.x, b.x + b.w / 2, b.x + b.w]) it.xs.push({ v, b });
for (const v of [b.y, b.y + b.h / 2, b.y + b.h]) it.ys.push({ v, b });
}
}
let dx = p.x - it.start.x;
let dy = p.y - it.start.y;
let sx = null;
let sy = null;
const b = it.box;
if (b && !e.altKey) {
const th = 6 / ed.view.zoom;
sx = snapAxis(b.x + dx, b.w, it.xs, th);
sy = snapAxis(b.y + dy, b.h, it.ys, th);
dx = sx ? dx + sx.d : snap(b.x + dx) - b.x;
dy = sy ? dy + sy.d : snap(b.y + dy) - b.y;
}
if (e.shiftKey) {
if (Math.abs(p.x - it.start.x) >= Math.abs(p.y - it.start.y)) { dy = 0; sy = null; } else { dx = 0; sx = null; }
}
ed.guides = [];
if (b) {
const nb = { x: b.x + dx, y: b.y + dy, w: b.w, h: b.h };
if (sx) ed.guides.push(guideLine("x", sx.v, nb, it.xs));
if (sy) ed.guides.push(guideLine("y", sy.v, nb, it.ys));
}
ed.setElements(it.orig.map((el) => (it.ids.has(el.id) ? translateElement(el, dx, dy) : el)));
}
function dragMarquee(p) {
const m = {
x: Math.min(it.start.x, p.x), y: Math.min(it.start.y, p.y),
w: Math.abs(p.x - it.start.x), h: Math.abs(p.y - it.start.y),
};
ed.marquee = m;
const inside = ed.elements.filter((el) => {
const b = elementBounds(el, ed.byId, ed.measure);
return b && boxContains(m, b);
});
ed.select([...it.baseSel, ...inside.map((el) => el.id)]);
}
function dragResize(p, e) {
const s = it.orig;
const west = it.which.includes("w");
const north = it.which.includes("n");
const fx = west ? s.x + s.w : s.x;
const fy = north ? s.y + s.h : s.y;
const px = grid(p.x, e);
const py = grid(p.y, e);
let w = clamp(west ? fx - px : px - fx, 20, MAX_SIDE);
let h = clamp(north ? fy - py : py - fy, 20, MAX_SIDE);
if (e.shiftKey) {
// Keeps the proportions, up to the size limit.
const k = Math.min(Math.max(w / s.w, h / s.h), MAX_SIDE / Math.max(s.w, s.h));
w = Math.max(20, s.w * k);
h = Math.max(20, s.h * k);
}
ed.replaceElement({ ...s, x: r2(west ? fx - w : fx), y: r2(north ? fy - h : fy), w: r2(w), h: r2(h) });
}
function dragEndpoint(p, e) {
const a = it.orig;
const isStart = it.which === "start";
const other = isStart ? a.to : a.from;
const target = e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol(), other);
ed.target = target ? target.id : null;
const x = target ? r2(p.x) : grid(p.x, e);
const y = target ? r2(p.y) : grid(p.y, e);
const id = target ? target.id : null;
ed.replaceElement(isStart ? { ...a, from: id, x1: x, y1: y } : { ...a, to: id, x2: x, y2: y });
}
// Shared by the connect handle and the arrow tool: a live arrow from `from` to the pointer.
function liveArrow(p, e, from, extra = {}) {
const src = from ? ed.byId.get(from) : null;
const inside = src && pointInShape(src, p.x, p.y, 2 / ed.view.zoom);
const target = inside || e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol(), from);
ed.target = target ? target.id : null;
if (inside) {
if (it.arrowId && ed.byId.has(it.arrowId)) ed.setElements(ed.elements.filter((el) => el.id !== it.arrowId));
return;
}
if (!it.arrowId) it.arrowId = newId("a");
const x2 = target ? r2(p.x) : grid(p.x, e);
const y2 = target ? r2(p.y) : grid(p.y, e);
ed.upsertElement(ed.newArrow({ id: it.arrowId, from, to: target ? target.id : null, x2, y2, ...extra }));
}
function dragConnect(p, e) {
liveArrow(p, e, it.src);
}
function dragArrow(p, e) {
liveArrow(p, e, it.from, { x1: it.x1, y1: it.y1 });
}
function dragCreate(p, e) {
const x0 = grid(it.start.x, e);
const y0 = grid(it.start.y, e);
const x1 = grid(p.x, e);
const y1 = grid(p.y, e);
let w = Math.abs(x1 - x0);
let h = Math.abs(y1 - y0);
if (e.shiftKey) w = h = Math.max(w, h);
if (!it.id) it.id = newId(idPrefix(it.type));
ed.upsertElement(ed.newShape(it.type, {
id: it.id, x: x1 < x0 ? x0 - w : x0, y: y1 < y0 ? y0 - h : y0, w: Math.max(10, w), h: Math.max(10, h),
}));
}
function arrowLength(id) {
const a = ed.byId.get(id);
const g = a && arrowGeometry(a, ed.byId);
return g ? Math.hypot(g.end.x - g.start.x, g.end.y - g.start.y) : 0;
}
function createText(p) {
const before = ed.elements;
const lh = lineHeight(fontSizeOf(ed.styles.text));
const t = ed.newText({ x: snap(p.x), y: snap(p.y - lh / 2) });
ed.setElements([...ed.elements, t], { record: false });
ed.select([t.id]);
ed.setTool("select");
ed.labels.open(t.id, { before });
}
function editOrCreateText(p) {
const hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure);
if (hit && hit.type !== "pen") {
ed.setTool("select");
ed.select([hit.id]);
ed.labels.open(hit.id);
} else {
createText(p);
}
}
function up(e) {
if (it && e.pointerId === it.pointerId) finish(e, false);
}
function finish(e, cancelled) {
const cur = it;
it = null;
try {
svg.releasePointerCapture(cur.pointerId);
} catch {
// Already released.
}
if (cancelled) {
ed.endInteraction({ revert: cur.kind !== "pan" && cur.kind !== "marquee" });
ed.refreshCursor();
return;
}
const start = ed.changeStart;
const p = cur.moved ? ed.lastPointer || cur.start : cur.start;
let after = null;
if (cur.kind === "move") {
if (!cur.moved && !cur.shift && ed.selection.size > 1) ed.select([cur.hit]);
} else if (cur.kind === "endpoint") {
if (arrowLength(cur.orig.id) < 4) ed.replaceElement(cur.orig);
} else if (cur.kind === "dbl") {
const hit = cur.hit ? ed.byId.get(cur.hit) : null;
after = () => {
if (!hit) return createText(cur.start);
ed.select([hit.id]);
if (hit.type !== "pen") ed.labels.open(hit.id);
};
} else if (cur.kind === "text") {
after = () => editOrCreateText(cur.start);
} else if (cur.kind === "pen") {
const el = normalizeElement({ id: newId("p"), type: "pen", points: fitStroke(cur.points, penTolerance()), color: ed.styles.pen.color, width: ed.styles.pen.width });
if (el) ed.setElements([...ed.elements, el]);
} else if (cur.kind === "connect") {
if (!cur.moved) {
after = () => ed.addConnected(cur.src, cur.dir);
} else if (cur.arrowId && ed.byId.has(cur.arrowId)) {
const arrow = ed.byId.get(cur.arrowId);
const src = ed.byId.get(cur.src);
const ddx = p.x - (src.x + src.w / 2);
const ddy = p.y - (src.y + src.h / 2);
ed.lastDirection = Math.abs(ddx) >= Math.abs(ddy) ? (ddx >= 0 ? "right" : "left") : (ddy >= 0 ? "down" : "up");
if (arrow.to) {
after = () => ed.select([arrow.id]);
} else {
const shape = ed.newShape(src.type, {
...src, id: newId(idPrefix(src.type)), text: "", x: snap(p.x - src.w / 2), y: snap(p.y - src.h / 2),
});
ed.setElements([...ed.elements.map((el) => (el.id === arrow.id ? { ...el, to: shape.id } : el)), shape]);
ed.endInteraction({ record: false });
ed.select([shape.id]);
ed.labels.open(shape.id, { fresh: true, before: start });
ed.refreshCursor();
return;
}
}
} else if (cur.kind === "arrow") {
if (!cur.arrowId || !ed.byId.has(cur.arrowId)) {
if (!cur.moved) ed.emit("hint", "Drag to draw an arrow. Start on a shape to connect it.");
} else if (arrowLength(cur.arrowId) < 8) {
ed.setElements(ed.elements.filter((el) => el.id !== cur.arrowId));
} else {
after = () => {
ed.select([cur.arrowId]);
ed.setTool("select");
};
}
} else if (cur.kind === "create") {
let el = cur.id ? ed.byId.get(cur.id) : null;
const fresh = !el || el.w < 20 || el.h < 20;
if (fresh) {
const [w, h] = DEFAULT_SIZES[cur.type];
el = ed.newShape(cur.type, { id: cur.id || newId(idPrefix(cur.type)), x: snap(cur.start.x - w / 2), y: snap(cur.start.y - h / 2), w, h });
ed.upsertElement(el);
}
ed.endInteraction({ record: false });
ed.select([el.id]);
ed.setTool("select");
ed.labels.open(el.id, { fresh, before: start });
ed.refreshCursor();
return;
}
ed.endInteraction();
if (after) after();
ed.refreshCursor();
}
function cancel(e) {
if (it && e.pointerId === it.pointerId) finish(e, true);
}
function wheel(e) {
e.preventDefault();
const s = local(e);
if (e.ctrlKey || e.metaKey) {
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
ed.zoomAt(Math.exp(-dy * 0.002), s.x, s.y);
return;
}
let dx = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX;
let dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
if (e.shiftKey && !dx) {
dx = dy;
dy = 0;
}
ed.panBy(dx, dy);
}
svg.addEventListener("pointerdown", down);
svg.addEventListener("pointermove", move);
svg.addEventListener("pointerup", up);
svg.addEventListener("pointercancel", cancel);
svg.addEventListener("lostpointercapture", (e) => {
if (it && e.pointerId === it.pointerId) finish(e, false);
});
svg.addEventListener("pointerleave", () => {
if (!it) setHover(null, null);
});
svg.addEventListener("mousedown", (e) => e.preventDefault());
svg.addEventListener("contextmenu", (e) => e.preventDefault());
svg.addEventListener("wheel", wheel, { passive: false });
ed.cancelPointer = () => {
if (!it) return;
const cur = it;
it = null;
try {
svg.releasePointerCapture(cur.pointerId);
} catch {
// Already released.
}
ed.endInteraction({ revert: cur.kind !== "pan" && cur.kind !== "marquee" });
ed.refreshCursor();
};
ed.pointerActive = () => !!it;
}