mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 15:24:25 +00:00
752 lines
28 KiB
JavaScript
752 lines
28 KiB
JavaScript
// Editor core: elements, selection, view, history, rendering and element-level commands.
|
|
import {
|
|
isShape, snap, clamp, normalizeElement, normalizeElements, removeWithArrows, newId, idPrefix, sameJson,
|
|
PEN_WIDTHS, SHAPE_TYPES, MAX_SIDE, DEFAULT_SIZES,
|
|
} from "/lib/model.mjs";
|
|
import { renderElements, renderStandaloneSVG, outline, penPath } from "/lib/render.mjs";
|
|
import {
|
|
fmt, arrowGeometry, elementBounds, contentBounds, unionBounds, neededHeight, lineHeight, fontSizeOf,
|
|
} from "/lib/geometry.mjs";
|
|
import { findFreeSpot } from "/lib/layout.mjs";
|
|
import { describeElement } from "./announce.js";
|
|
|
|
export const MIN_ZOOM = 0.1;
|
|
export const MAX_ZOOM = 4;
|
|
const HISTORY_LIMIT = 200;
|
|
const round = (v) => Math.round(v * 100) / 100;
|
|
// The model keeps pen points to one decimal, so moved points are rounded the same way.
|
|
const roundPoint = (v) => Math.round(v * 10) / 10;
|
|
export const STYLE_KEYS = {
|
|
shape: ["color", "fill", "dash", "size", "type"],
|
|
text: ["color", "size"],
|
|
arrow: ["color", "dash", "head", "route", "size"],
|
|
pen: ["color", "width"],
|
|
};
|
|
|
|
export const categoryOf = (el) => (isShape(el) ? "shape" : el.type);
|
|
const sameElements = (a, b) => a === b || (a.length === b.length && a.every((e, i) => e === b[i]));
|
|
|
|
export function translateElement(el, dx, dy) {
|
|
if (el.type === "pen") return { ...el, points: el.points.map(([x, y]) => [roundPoint(x + dx), roundPoint(y + dy)]) };
|
|
if (el.type === "arrow") {
|
|
const next = { ...el };
|
|
if (!el.from) { next.x1 = round(el.x1 + dx); next.y1 = round(el.y1 + dy); }
|
|
if (!el.to) { next.x2 = round(el.x2 + dx); next.y2 = round(el.y2 + dy); }
|
|
return next;
|
|
}
|
|
return { ...el, x: round(el.x + dx), y: round(el.y + dy) };
|
|
}
|
|
|
|
export class Editor {
|
|
constructor(dom, theme) {
|
|
Object.assign(this, dom);
|
|
this.theme = theme;
|
|
this.elements = [];
|
|
this.byId = new Map();
|
|
this.selection = new Set();
|
|
this.tool = "select";
|
|
this.view = { x: 0, y: 0, zoom: 1 };
|
|
this.history = [];
|
|
this.future = [];
|
|
this.handlers = new Map();
|
|
this.interaction = null;
|
|
this.changeStart = null;
|
|
this.editing = null;
|
|
this.hover = null;
|
|
this.hoverHandle = null;
|
|
this.target = null;
|
|
// The shape an arrow made from the keyboard starts at, until Enter picks where it ends.
|
|
this.linkFrom = null;
|
|
this.marquee = null;
|
|
this.guides = [];
|
|
this.preview = null;
|
|
this.spaceDown = false;
|
|
this.lastPointer = null;
|
|
this.lastDirection = "right";
|
|
this.contentDirty = true;
|
|
this.raf = 0;
|
|
this.styles = {
|
|
shape: { color: "gray", fill: "soft", dash: false, size: "m" },
|
|
text: { color: "gray", size: "l" },
|
|
arrow: { color: "gray", dash: false, head: "end", route: "straight", size: "s" },
|
|
pen: { color: "gray", width: PEN_WIDTHS.m },
|
|
};
|
|
new ResizeObserver(() => {
|
|
if (this.needsFit && this.stage.clientWidth && this.stage.clientHeight) {
|
|
this.needsFit = false;
|
|
this.fit();
|
|
}
|
|
this.requestRender();
|
|
}).observe(this.stage);
|
|
}
|
|
|
|
get measure() { return this.theme.measure; }
|
|
get paint() { return this.theme.paint; }
|
|
get busy() { return !!(this.interaction || this.editing); }
|
|
|
|
on(name, fn) {
|
|
if (!this.handlers.has(name)) this.handlers.set(name, new Set());
|
|
this.handlers.get(name).add(fn);
|
|
}
|
|
|
|
emit(name, data) {
|
|
for (const fn of this.handlers.get(name) || []) fn(data);
|
|
}
|
|
|
|
// ---------- elements and history ----------
|
|
|
|
setElements(next, { record = true, emit = true } = {}) {
|
|
if (next === this.elements) return;
|
|
if (record && !this.changeStart) this.pushHistory(this.elements);
|
|
this.elements = next;
|
|
this.byId = new Map(next.map((e) => [e.id, e]));
|
|
let pruned = false;
|
|
for (const id of this.selection) if (!this.byId.has(id)) { this.selection.delete(id); pruned = true; }
|
|
if (this.hover && !this.byId.has(this.hover)) this.hover = null;
|
|
if (this.linkFrom && !this.byId.has(this.linkFrom)) this.linkFrom = null;
|
|
this.contentDirty = true;
|
|
this.requestRender();
|
|
if (pruned) this.emit("selection");
|
|
if (emit) this.emit("change");
|
|
}
|
|
|
|
replaceElement(el, opts) {
|
|
this.setElements(this.elements.map((e) => (e.id === el.id ? el : e)), opts);
|
|
}
|
|
|
|
upsertElement(el, opts) {
|
|
if (this.byId.has(el.id)) this.replaceElement(el, opts);
|
|
else this.setElements([...this.elements, el], opts);
|
|
}
|
|
|
|
pushHistory(snapshot) {
|
|
this.history.push(snapshot);
|
|
if (this.history.length > HISTORY_LIMIT) this.history.shift();
|
|
this.future = [];
|
|
this.emit("history");
|
|
}
|
|
|
|
beginInteraction(it) {
|
|
this.interaction = it;
|
|
this.changeStart = this.elements;
|
|
this.requestRender();
|
|
}
|
|
|
|
// record: false leaves the undo point to a label edit that follows (creation + typing = one step).
|
|
endInteraction({ revert = false, record = true } = {}) {
|
|
const start = this.changeStart;
|
|
this.changeStart = null;
|
|
this.interaction = null;
|
|
this.guides = [];
|
|
this.target = null;
|
|
this.marquee = null;
|
|
this.preview = null;
|
|
if (revert && start) this.setElements(start, { record: false });
|
|
else if (record && start && !sameElements(start, this.elements)) this.pushHistory(start);
|
|
this.contentDirty = true;
|
|
this.requestRender();
|
|
this.emit("change");
|
|
this.emit("idle");
|
|
}
|
|
|
|
undo() {
|
|
if (this.busy) return;
|
|
const prev = this.history.pop();
|
|
if (!prev) return;
|
|
this.future.push(this.elements);
|
|
this.setElements(prev, { record: false });
|
|
this.emit("history");
|
|
}
|
|
|
|
redo() {
|
|
if (this.busy) return;
|
|
const next = this.future.pop();
|
|
if (!next) return;
|
|
this.history.push(this.elements);
|
|
this.setElements(next, { record: false });
|
|
this.emit("history");
|
|
}
|
|
|
|
load(doc) {
|
|
this.cancelPointer?.();
|
|
if (this.editing) this.labels.close();
|
|
this.elements = doc.elements;
|
|
this.byId = new Map(doc.elements.map((e) => [e.id, e]));
|
|
this.selection = new Set();
|
|
this.history = [];
|
|
this.future = [];
|
|
this.hover = null;
|
|
this.linkFrom = null;
|
|
this.contentDirty = true;
|
|
this.fit();
|
|
this.emit("history");
|
|
this.emit("selection");
|
|
this.emit("change", { loaded: true });
|
|
}
|
|
|
|
// Remote (agent) changes: undoable, and new content is brought into view. record: false is for
|
|
// the server tidying up our own change (a size past the limit, say), which is no step to undo.
|
|
applyRemote(next, { record = true } = {}) {
|
|
const before = this.elements;
|
|
if (sameJson(before, next)) return;
|
|
const known = new Set(before.map((e) => e.id));
|
|
this.setElements(next, { record });
|
|
const added = next.filter((e) => !known.has(e.id));
|
|
if (!before.length) this.fit();
|
|
else if (added.length && !this.isVisible(added)) this.fit({ maxZoom: this.view.zoom });
|
|
}
|
|
|
|
// ---------- creation helpers ----------
|
|
|
|
newShape(type, props = {}) {
|
|
return normalizeElement({ ...this.styles.shape, id: newId(idPrefix(type)), type, ...props });
|
|
}
|
|
|
|
newText(props = {}) {
|
|
return normalizeElement({ ...this.styles.text, id: newId("t"), type: "text", text: "", ...props });
|
|
}
|
|
|
|
newArrow(props = {}) {
|
|
return normalizeElement({ ...this.styles.arrow, id: newId("a"), type: "arrow", ...props });
|
|
}
|
|
|
|
obstacles(exclude = null) {
|
|
return this.elements
|
|
.filter((e) => e.type !== "arrow" && e.id !== exclude)
|
|
.map((e) => elementBounds(e, this.byId, this.measure))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Adds a copy of a shape next to it in a direction, connected with an arrow, and edits its label.
|
|
addConnected(sourceId, dir = "right", { from = sourceId, anchorId = sourceId } = {}) {
|
|
const src = this.byId.get(sourceId);
|
|
const anchor = this.byId.get(anchorId);
|
|
if (!src || !isShape(src) || !anchor) return null;
|
|
const gap = dir === "left" || dir === "right" ? 80 : 60;
|
|
const spot = findFreeSpot(anchor, dir, src.w, src.h, this.obstacles(), gap);
|
|
const shape = normalizeElement({ ...src, id: newId(idPrefix(src.type)), text: "", x: snap(spot.x), y: snap(spot.y) });
|
|
const next = [...this.elements, shape];
|
|
if (from) next.push(this.newArrow({ from, to: shape.id }));
|
|
const before = this.elements;
|
|
this.setElements(next, { record: false });
|
|
this.lastDirection = dir;
|
|
this.select([shape.id]);
|
|
this.ensureVisible([shape]);
|
|
this.labels.open(shape.id, { fresh: true, before });
|
|
return shape;
|
|
}
|
|
|
|
// Shift+Tab: another child of the same parent (or a plain copy below when there is no parent).
|
|
addSibling(id) {
|
|
const s = this.byId.get(id);
|
|
if (!s || !isShape(s)) return null;
|
|
const incoming = this.elements.find((e) => e.type === "arrow" && e.to === id && e.from);
|
|
const across = this.lastDirection === "down" || this.lastDirection === "up" ? "right" : "down";
|
|
return this.addConnected(id, across, { from: incoming ? incoming.from : null });
|
|
}
|
|
|
|
// Enter with a shape or text tool adds one without a mouse: in the middle of the view, or the
|
|
// nearest free spot beside it. Then it edits the label, as a click on the canvas does.
|
|
addAtCenter(type) {
|
|
const text = type === "text";
|
|
if (!text && !SHAPE_TYPES.includes(type)) return null;
|
|
const [w, h] = text ? [120, lineHeight(fontSizeOf(this.styles.text))] : DEFAULT_SIZES[type];
|
|
const c = this.viewCenter();
|
|
// The first spot findFreeSpot tries is one gap past its anchor, so this one centers it.
|
|
const spot = findFreeSpot({ x: c.x - w / 2 - 40, y: c.y - h / 2, w: 0, h }, "right", w, h, this.obstacles(), 40);
|
|
const at = { x: snap(spot.x), y: snap(spot.y) };
|
|
const el = text ? this.newText(at) : this.newShape(type, { ...at, w, h });
|
|
const before = this.elements;
|
|
this.setElements([...this.elements, el], { record: false });
|
|
this.select([el.id]);
|
|
this.setTool("select");
|
|
this.ensureVisible([el]);
|
|
this.labels.open(el.id, { fresh: !text, before });
|
|
return el;
|
|
}
|
|
|
|
// Enter with the arrow tool connects shapes without a mouse: the first Enter starts an arrow at
|
|
// the selected shape, and the next one ends it at the shape selected by then.
|
|
linkStep() {
|
|
const one = this.single();
|
|
const from = this.linkFrom ? this.byId.get(this.linkFrom) : null;
|
|
if (!from) {
|
|
if (!one || !isShape(one)) return this.emit("hint", "Select a shape with N first, then press Enter to start an arrow from it.");
|
|
this.linkFrom = one.id;
|
|
this.requestRender();
|
|
return this.emit("announce", `Arrow from ${describeElement(one, this.byId)}. Select the shape it goes to with N, then press Enter.`);
|
|
}
|
|
if (!one || !isShape(one) || one.id === from.id) {
|
|
return this.emit("hint", "Select the shape the arrow goes to with N, then press Enter. Esc cancels.");
|
|
}
|
|
const arrow = this.newArrow({ from: from.id, to: one.id });
|
|
this.setElements([...this.elements, arrow]);
|
|
this.select([arrow.id]);
|
|
this.setTool("select");
|
|
this.emit("announce", `Added an arrow from ${describeElement(from, this.byId)} to ${describeElement(one, this.byId)}.`);
|
|
}
|
|
|
|
// ---------- selection ----------
|
|
|
|
selected() {
|
|
return this.elements.filter((e) => this.selection.has(e.id));
|
|
}
|
|
|
|
single() {
|
|
return this.selection.size === 1 ? this.byId.get([...this.selection][0]) || null : null;
|
|
}
|
|
|
|
// N and Shift+N: select the next or previous element in drawing order, wrapping around.
|
|
selectStep(delta) {
|
|
const n = this.elements.length;
|
|
if (!n) return null;
|
|
const last = [...this.selection].pop();
|
|
const at = last ? this.elements.findIndex((e) => e.id === last) : -1;
|
|
const index = at < 0 ? (delta > 0 ? 0 : n - 1) : (at + delta + n) % n;
|
|
const el = this.elements[index];
|
|
this.select([el.id]);
|
|
this.ensureVisible([el]);
|
|
return { el, index, total: n };
|
|
}
|
|
|
|
select(ids, { toggle = false, add = false } = {}) {
|
|
const next = toggle || add ? new Set(this.selection) : new Set();
|
|
for (const id of ids) {
|
|
if (!this.byId.has(id)) continue;
|
|
if (toggle && next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
}
|
|
if (next.size === this.selection.size && [...next].every((id) => this.selection.has(id))) return;
|
|
this.selection = next;
|
|
this.requestRender();
|
|
this.emit("selection");
|
|
}
|
|
|
|
selectAll() {
|
|
this.setTool("select");
|
|
this.select(this.elements.map((e) => e.id));
|
|
}
|
|
|
|
deleteSelection() {
|
|
if (!this.selection.size) return;
|
|
this.setElements(removeWithArrows(this.elements, [...this.selection]).elements);
|
|
}
|
|
|
|
nudge(dx, dy) {
|
|
if (!this.selection.size) return;
|
|
this.setElements(this.elements.map((e) => (this.selection.has(e.id) ? translateElement(e, dx, dy) : e)));
|
|
}
|
|
|
|
// Ctrl+Shift+Arrow: resizes the selected shapes, keeping their top left corner. Like dragging a
|
|
// handle, it stops at 20 px, but a shape that is smaller already keeps its size when shrunk.
|
|
resizeBy(dw, dh) {
|
|
const shapes = this.selected().filter(isShape);
|
|
if (!shapes.length) return this.emit("hint", "Select a shape to resize it.");
|
|
const sized = new Map();
|
|
for (const s of shapes) {
|
|
const w = clamp(s.w + dw, Math.min(20, s.w), MAX_SIDE);
|
|
const h = clamp(s.h + dh, Math.min(20, s.h), MAX_SIDE);
|
|
if (w !== s.w || h !== s.h) sized.set(s.id, normalizeElement({ ...s, w, h }));
|
|
}
|
|
if (sized.size) this.setElements(this.elements.map((e) => sized.get(e.id) || e));
|
|
const one = this.single();
|
|
if (one && isShape(one)) this.emit("announce", `${Math.round(one.w)} by ${Math.round(one.h)}`);
|
|
}
|
|
|
|
reorder(toFront) {
|
|
if (!this.selection.size) return;
|
|
const picked = this.elements.filter((e) => this.selection.has(e.id));
|
|
if (picked.length && picked.every((e) => e.type === "arrow")) {
|
|
this.emit("hint", "Arrows always stay on top of shapes.");
|
|
return;
|
|
}
|
|
const rest = this.elements.filter((e) => !this.selection.has(e.id));
|
|
this.setElements(toFront ? [...rest, ...picked] : [...picked, ...rest]);
|
|
}
|
|
|
|
// Copies of elements with fresh ids. Arrow ends bound outside the set become free ends.
|
|
cloneSet(list, dx, dy) {
|
|
const ids = new Map(list.map((e) => [e.id, newId(idPrefix(e.type))]));
|
|
const byId = new Map([...this.byId, ...list.map((e) => [e.id, e])]);
|
|
const out = [];
|
|
for (const el of list) {
|
|
let copy = { ...el, id: ids.get(el.id) };
|
|
if (el.type === "arrow") {
|
|
const g = arrowGeometry(el, byId);
|
|
if (!g) continue;
|
|
copy.from = ids.get(el.from) || null;
|
|
copy.to = ids.get(el.to) || null;
|
|
if (!copy.from) { copy.x1 = round(g.start.x); copy.y1 = round(g.start.y); }
|
|
if (!copy.to) { copy.x2 = round(g.end.x); copy.y2 = round(g.end.y); }
|
|
}
|
|
copy = normalizeElement(translateElement(copy, dx, dy));
|
|
if (copy) out.push(copy);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
duplicate() {
|
|
const list = this.selected();
|
|
if (!list.length) return;
|
|
const copies = this.cloneSet(list, 20, 20);
|
|
this.setElements([...this.elements, ...copies]);
|
|
this.select(copies.map((e) => e.id));
|
|
}
|
|
|
|
copyText() {
|
|
const list = this.selected();
|
|
if (!list.length) return null;
|
|
return JSON.stringify({ type: "copilot-draw", version: 1, elements: this.cloneSet(list, 0, 0) });
|
|
}
|
|
|
|
paste(text) {
|
|
let data = null;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
// Plain text.
|
|
}
|
|
const p = this.lastPointer || this.viewCenter();
|
|
if (data && data.type === "copilot-draw" && Array.isArray(data.elements)) {
|
|
const list = normalizeElements(data.elements);
|
|
if (!list.length) return;
|
|
const b = contentBounds(list, this.measure);
|
|
const dx = snap(p.x - (b.x + b.w / 2));
|
|
const dy = snap(p.y - (b.y + b.h / 2));
|
|
const copies = this.cloneSet(list, dx, dy);
|
|
this.setElements([...this.elements, ...copies]);
|
|
this.select(copies.map((e) => e.id));
|
|
return;
|
|
}
|
|
const str = String(text).trim();
|
|
if (!str) return;
|
|
const el = this.newText({ text: str.slice(0, 4000), x: snap(p.x), y: snap(p.y) });
|
|
this.setElements([...this.elements, el]);
|
|
this.select([el.id]);
|
|
}
|
|
|
|
// ---------- styles ----------
|
|
|
|
toolCategory() {
|
|
if (SHAPE_TYPES.includes(this.tool)) return "shape";
|
|
return ["text", "arrow", "pen"].includes(this.tool) ? this.tool : null;
|
|
}
|
|
|
|
// Patch keys: color, fill, dash, head, route, size, width, type. Only keys that fit each element apply.
|
|
applyStyle(patch) {
|
|
const sel = this.selected();
|
|
const pick = (cat) => Object.fromEntries(Object.entries(patch).filter(([k]) => STYLE_KEYS[cat].includes(k)));
|
|
if (sel.length) {
|
|
const next = this.elements.map((el) => {
|
|
if (!this.selection.has(el.id)) return el;
|
|
const p = pick(categoryOf(el));
|
|
if (!Object.keys(p).length) return el;
|
|
let out = normalizeElement({ ...el, ...p }) || el;
|
|
if (isShape(out)) {
|
|
// Tall enough for the label, up to the size limit, so it stays centered where it was.
|
|
const need = Math.min(MAX_SIDE, Math.ceil(neededHeight(out, this.measure) / 10) * 10);
|
|
if (need > out.h) out = { ...out, y: round(out.y - (need - out.h) / 2), h: need };
|
|
}
|
|
return out;
|
|
});
|
|
this.setElements(next);
|
|
for (const el of sel) {
|
|
const { type, ...rest } = pick(categoryOf(el));
|
|
Object.assign(this.styles[categoryOf(el)], rest);
|
|
}
|
|
} else {
|
|
const cat = this.toolCategory();
|
|
if (cat) {
|
|
const { type, ...rest } = pick(cat);
|
|
Object.assign(this.styles[cat], rest);
|
|
}
|
|
}
|
|
this.emit("style");
|
|
}
|
|
|
|
setTool(tool) {
|
|
if (tool === this.tool) return;
|
|
if (this.editing) this.labels.commit();
|
|
this.tool = tool;
|
|
this.linkFrom = null;
|
|
if (tool !== "select") this.hover = null;
|
|
this.requestRender();
|
|
this.emit("tool", tool);
|
|
}
|
|
|
|
// The canvas is the SVG: it takes the keys, and screen readers hear it as the drawing.
|
|
focusCanvas() {
|
|
this.svg.focus({ preventScroll: true });
|
|
}
|
|
|
|
setTheme(theme) {
|
|
this.theme = theme;
|
|
this.contentDirty = true;
|
|
this.requestRender();
|
|
}
|
|
|
|
refresh() {
|
|
this.contentDirty = true;
|
|
this.requestRender();
|
|
}
|
|
|
|
// ---------- view ----------
|
|
|
|
viewport() {
|
|
const r = this.stage.getBoundingClientRect();
|
|
return { w: r.width, h: r.height, left: r.left, top: r.top };
|
|
}
|
|
|
|
toWorld(clientX, clientY) {
|
|
const r = this.stage.getBoundingClientRect();
|
|
return { x: (clientX - r.left) / this.view.zoom + this.view.x, y: (clientY - r.top) / this.view.zoom + this.view.y };
|
|
}
|
|
|
|
toScreen(x, y) {
|
|
return { x: (x - this.view.x) * this.view.zoom, y: (y - this.view.y) * this.view.zoom };
|
|
}
|
|
|
|
viewCenter() {
|
|
const { w, h } = this.viewport();
|
|
return { x: this.view.x + w / 2 / this.view.zoom, y: this.view.y + h / 2 / this.view.zoom };
|
|
}
|
|
|
|
setView(view) {
|
|
this.view = view;
|
|
this.requestRender();
|
|
this.emit("view");
|
|
}
|
|
|
|
panBy(dx, dy) {
|
|
this.setView({ ...this.view, x: this.view.x + dx / this.view.zoom, y: this.view.y + dy / this.view.zoom });
|
|
}
|
|
|
|
zoomAt(factor, sx, sy) {
|
|
const z0 = this.view.zoom;
|
|
const z = clamp(z0 * factor, MIN_ZOOM, MAX_ZOOM);
|
|
const wx = sx / z0 + this.view.x;
|
|
const wy = sy / z0 + this.view.y;
|
|
this.setView({ zoom: z, x: wx - sx / z, y: wy - sy / z });
|
|
}
|
|
|
|
zoomBy(factor) {
|
|
const { w, h } = this.viewport();
|
|
this.zoomAt(factor, w / 2, h / 2);
|
|
}
|
|
|
|
zoomTo(z) {
|
|
this.zoomBy(z / this.view.zoom);
|
|
}
|
|
|
|
// Room left for the floating tool rail, style bar and zoom controls.
|
|
insets() {
|
|
return { l: 64, r: 24, t: 56, b: 52 };
|
|
}
|
|
|
|
fitBounds(b, maxZoom = 1) {
|
|
const { w, h } = this.viewport();
|
|
if (!w || !h) return;
|
|
const m = this.insets();
|
|
const aw = Math.max(80, w - m.l - m.r);
|
|
const ah = Math.max(80, h - m.t - m.b);
|
|
const zoom = clamp(Math.min(aw / Math.max(b.w, 1), ah / Math.max(b.h, 1), maxZoom), MIN_ZOOM, MAX_ZOOM);
|
|
const cx = m.l + aw / 2;
|
|
const cy = m.t + ah / 2;
|
|
this.setView({ zoom, x: b.x + b.w / 2 - cx / zoom, y: b.y + b.h / 2 - cy / zoom });
|
|
}
|
|
|
|
fit({ maxZoom = 1 } = {}) {
|
|
const { w, h } = this.viewport();
|
|
if (!w || !h) {
|
|
this.needsFit = true;
|
|
return;
|
|
}
|
|
const b = contentBounds(this.elements, this.measure);
|
|
if (b) return this.fitBounds(b, maxZoom);
|
|
this.setView({ zoom: 1, x: -Math.round(w / 2), y: -Math.round(h / 2) });
|
|
}
|
|
|
|
isVisible(list) {
|
|
const b = unionBounds(list.map((e) => elementBounds(e, this.byId, this.measure)));
|
|
if (!b) return true;
|
|
const { w, h } = this.viewport();
|
|
const z = this.view.zoom;
|
|
const v = { x: this.view.x, y: this.view.y, w: w / z, h: h / z };
|
|
return b.x >= v.x && b.y >= v.y && b.x + b.w <= v.x + v.w && b.y + b.h <= v.y + v.h;
|
|
}
|
|
|
|
ensureVisible(list) {
|
|
if (this.isVisible(list)) return;
|
|
const b = unionBounds(list.map((e) => elementBounds(e, this.byId, this.measure)));
|
|
if (!b) return;
|
|
const { w, h } = this.viewport();
|
|
const z = this.view.zoom;
|
|
if (b.w * z < w - 120 && b.h * z < h - 120) {
|
|
this.setView({ zoom: z, x: b.x + b.w / 2 - w / 2 / z, y: b.y + b.h / 2 - h / 2 / z });
|
|
} else {
|
|
this.fitBounds(b, z);
|
|
}
|
|
}
|
|
|
|
// ---------- export ----------
|
|
|
|
exportSVG({ background = true } = {}) {
|
|
return renderStandaloneSVG(this.elements, {
|
|
measure: this.measure,
|
|
paint: this.paint,
|
|
fontFamily: this.theme.font,
|
|
background: background ? this.theme.bg : null,
|
|
});
|
|
}
|
|
|
|
async exportPNG(scale = 2, maxSide = 8000) {
|
|
const svg = this.exportSVG();
|
|
const m = /width="(\d+)" height="(\d+)"/.exec(svg);
|
|
const w = Number(m[1]);
|
|
const h = Number(m[2]);
|
|
const img = new Image();
|
|
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
await img.decode();
|
|
const k = Math.min(scale, maxSide / Math.max(w, h));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(w * k));
|
|
canvas.height = Math.max(1, Math.round(h * k));
|
|
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Could not create the PNG."))), "image/png"));
|
|
}
|
|
|
|
// ---------- handles ----------
|
|
|
|
handles() {
|
|
const it = this.interaction;
|
|
if (this.editing || (it && it.kind !== "resize" && it.kind !== "endpoint")) return [];
|
|
const out = [];
|
|
const one = this.single();
|
|
if (one && isShape(one)) {
|
|
for (const [which, x, y] of [["nw", one.x, one.y], ["ne", one.x + one.w, one.y], ["sw", one.x, one.y + one.h], ["se", one.x + one.w, one.y + one.h]]) {
|
|
out.push({ kind: "resize", id: one.id, which, x, y, r: 4 });
|
|
}
|
|
} else if (one && one.type === "arrow") {
|
|
const g = arrowGeometry(one, this.byId);
|
|
if (g) {
|
|
out.push({ kind: "end", id: one.id, which: "start", x: g.start.x, y: g.start.y, r: 5, bound: !!one.from });
|
|
out.push({ kind: "end", id: one.id, which: "end", x: g.end.x, y: g.end.y, r: 5, bound: !!one.to });
|
|
}
|
|
}
|
|
if (!it && this.tool === "select") {
|
|
const id = this.hover || (one && isShape(one) ? one.id : null);
|
|
const s = id ? this.byId.get(id) : null;
|
|
const z = this.view.zoom;
|
|
if (s && isShape(s) && s.w * z >= 30 && s.h * z >= 20) {
|
|
const off = 20 / z;
|
|
out.push(
|
|
{ kind: "connect", id: s.id, which: "right", x: s.x + s.w + off, y: s.y + s.h / 2, r: 8 },
|
|
{ kind: "connect", id: s.id, which: "down", x: s.x + s.w / 2, y: s.y + s.h + off, r: 8 },
|
|
{ kind: "connect", id: s.id, which: "left", x: s.x - off, y: s.y + s.h / 2, r: 8 },
|
|
{ kind: "connect", id: s.id, which: "up", x: s.x + s.w / 2, y: s.y - off, r: 8 },
|
|
);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
handleAt(sx, sy) {
|
|
const list = this.handles();
|
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
const h = list[i];
|
|
const p = this.toScreen(h.x, h.y);
|
|
if (Math.hypot(p.x - sx, p.y - sy) <= h.r + 4) return h;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------- rendering ----------
|
|
|
|
requestRender() {
|
|
if (!this.raf) this.raf = requestAnimationFrame(() => this.render());
|
|
}
|
|
|
|
render() {
|
|
this.raf = 0;
|
|
const { x, y, zoom } = this.view;
|
|
this.world.setAttribute("transform", `scale(${zoom}) translate(${fmt(-x)} ${fmt(-y)})`);
|
|
if (this.contentDirty) {
|
|
this.content.innerHTML = renderElements(this.elements, {
|
|
measure: this.measure,
|
|
paint: this.paint,
|
|
hideText: this.editing ? this.editing.id : null,
|
|
});
|
|
this.contentDirty = false;
|
|
}
|
|
this.overlay.innerHTML = this.renderOverlay();
|
|
let step = 20;
|
|
while (step * zoom < 12) step *= 5;
|
|
const px = step * zoom;
|
|
this.stage.style.backgroundSize = `${px}px ${px}px`;
|
|
this.stage.style.backgroundPosition = `${-x * zoom}px ${-y * zoom}px`;
|
|
this.emit("render");
|
|
}
|
|
|
|
renderOverlay() {
|
|
const u = 1 / this.view.zoom;
|
|
const A = this.theme.accent;
|
|
const bg = this.theme.bg;
|
|
const sw = (px) => fmt(px * u);
|
|
const box = (b, pad, attrs) =>
|
|
`<rect x="${fmt(b.x - pad)}" y="${fmt(b.y - pad)}" width="${fmt(b.w + 2 * pad)}" height="${fmt(b.h + 2 * pad)}" ${attrs}/>`;
|
|
const parts = [];
|
|
if (this.preview) {
|
|
const p = this.preview;
|
|
parts.push(`<path d="${penPath(p.points)}" fill="none" stroke="${this.paint(p.color, "stroke")}" stroke-width="${fmt(p.width)}" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
}
|
|
const hovered = this.hover && !this.selection.has(this.hover) && !this.interaction ? this.byId.get(this.hover) : null;
|
|
if (hovered) parts.push(outline(hovered, ` fill="none" stroke="${A}" stroke-width="${sw(1.5)}" opacity="0.45"`));
|
|
const target = this.target ? this.byId.get(this.target) : null;
|
|
if (target) parts.push(outline(target, ` fill="${A}" fill-opacity="0.08" stroke="${A}" stroke-width="${sw(2)}"`));
|
|
// An arrow started from the keyboard: its start, and a dashed line to the selected shape.
|
|
const from = this.linkFrom ? this.byId.get(this.linkFrom) : null;
|
|
if (from) {
|
|
parts.push(outline(from, ` fill="${A}" fill-opacity="0.08" stroke="${A}" stroke-width="${sw(2)}"`));
|
|
const to = this.single();
|
|
const g = to && isShape(to) && to.id !== from.id ? arrowGeometry(this.newArrow({ id: "link", from: from.id, to: to.id }), this.byId) : null;
|
|
if (g) parts.push(`<path d="${g.d}" fill="none" stroke="${A}" stroke-width="${sw(1.5)}" stroke-dasharray="${sw(6)} ${sw(4)}" stroke-linecap="round"/>`);
|
|
}
|
|
const sel = this.selected();
|
|
for (const el of sel) {
|
|
if (el.type === "arrow") {
|
|
const g = arrowGeometry(el, this.byId);
|
|
if (g) parts.push(`<path d="${g.d}" fill="none" stroke="${A}" stroke-width="${sw(1.5)}" stroke-dasharray="${sw(4)} ${sw(3)}" stroke-linecap="round"/>`);
|
|
continue;
|
|
}
|
|
const b = elementBounds(el, this.byId, this.measure);
|
|
if (b) parts.push(box(b, 4 * u, `fill="none" stroke="${A}" stroke-width="${sw(1.5)}" rx="${sw(3)}"`));
|
|
}
|
|
if (sel.length > 1 && this.interaction?.kind !== "move") {
|
|
const b = unionBounds(sel.map((e) => elementBounds(e, this.byId, this.measure)));
|
|
if (b) parts.push(box(b, 10 * u, `fill="none" stroke="${A}" stroke-width="${sw(1)}" stroke-dasharray="${sw(4)} ${sw(4)}"`));
|
|
}
|
|
for (const h of this.handles()) parts.push(this.renderHandle(h, u, A, bg));
|
|
if (this.marquee) parts.push(box(this.marquee, 0, `fill="${A}" fill-opacity="0.07" stroke="${A}" stroke-width="${sw(1)}"`));
|
|
for (const g of this.guides) {
|
|
parts.push(`<line x1="${fmt(g.x1)}" y1="${fmt(g.y1)}" x2="${fmt(g.x2)}" y2="${fmt(g.y2)}" stroke="${A}" stroke-width="${sw(1)}" stroke-dasharray="${sw(3)} ${sw(3)}"/>`);
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
renderHandle(h, u, A, bg) {
|
|
const r = h.r * u;
|
|
const x = fmt(h.x);
|
|
const y = fmt(h.y);
|
|
if (h.kind === "resize") {
|
|
return `<rect x="${fmt(h.x - r)}" y="${fmt(h.y - r)}" width="${fmt(2 * r)}" height="${fmt(2 * r)}" rx="${fmt(1.5 * u)}" fill="${bg}" stroke="${A}" stroke-width="${fmt(1.5 * u)}"/>`;
|
|
}
|
|
if (h.kind === "end") {
|
|
return `<circle cx="${x}" cy="${y}" r="${fmt(r)}" fill="${h.bound ? A : bg}" stroke="${A}" stroke-width="${fmt(1.5 * u)}"/>`;
|
|
}
|
|
const hot = this.hoverHandle === `${h.kind}:${h.id}:${h.which}`;
|
|
const arm = 3.5 * u;
|
|
return `<g opacity="${hot ? 1 : 0.8}"><circle cx="${x}" cy="${y}" r="${fmt(r)}" fill="${hot ? A : bg}" stroke="${A}" stroke-width="${fmt(1.25 * u)}"/>` +
|
|
`<path d="M${fmt(h.x - arm)} ${y}H${fmt(h.x + arm)}M${x} ${fmt(h.y - arm)}V${fmt(h.y + arm)}" stroke="${hot ? bg : A}" stroke-width="${fmt(1.5 * u)}" stroke-linecap="round"/></g>`;
|
|
}
|
|
}
|