mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 23:34:25 +00:00
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
// Drawing data model shared by the extension (Node) and the editor (browser).
|
|
// Pure functions only: no DOM and no file system.
|
|
|
|
export const GRID = 10;
|
|
export const SHAPE_TYPES = ["rect", "ellipse", "diamond", "cylinder"];
|
|
export const ELEMENT_TYPES = [...SHAPE_TYPES, "text", "arrow", "pen"];
|
|
export const COLORS = ["gray", "blue", "green", "yellow", "orange", "red", "purple", "pink"];
|
|
export const FILLS = ["none", "soft", "solid"];
|
|
export const HEADS = ["end", "start", "both", "none"];
|
|
export const ROUTES = ["straight", "elbow", "curve"];
|
|
export const FONT_SIZES = { s: 13, m: 15, l: 20, xl: 28 };
|
|
export const SIZE_KEYS = Object.keys(FONT_SIZES);
|
|
export const PEN_WIDTHS = { s: 1.5, m: 2.5, l: 4, xl: 6 };
|
|
export const DEFAULT_SIZES = { rect: [160, 60], ellipse: [160, 80], diamond: [180, 120], cylinder: [120, 80] };
|
|
export const LABEL_WEIGHT = 500;
|
|
export const TEXT_WEIGHT = 400;
|
|
export const MAX_ELEMENTS = 5000;
|
|
// The biggest width or height a shape can have.
|
|
export const MAX_SIDE = 5000;
|
|
// The most points a pen stroke keeps. Low enough that even the biggest stroke still fits in the
|
|
// 64 KiB a page can send as it closes (see flushBeacon in public/sync.js).
|
|
export const MAX_PEN_POINTS = 2000;
|
|
// How far from 0 a position can be. One further out is moved back to it.
|
|
export const MAX_COORD = 1e6;
|
|
|
|
const ID_PREFIX = { rect: "r", ellipse: "o", diamond: "d", cylinder: "c", text: "t", arrow: "a", pen: "p" };
|
|
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
|
|
|
|
export const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
export const round2 = (v) => Math.round(v * 100) / 100;
|
|
export const snap = (v, grid = GRID) => Math.round(v / grid) * grid;
|
|
export const isShape = (el) => !!el && SHAPE_TYPES.includes(el.type);
|
|
const lower = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
|
|
|
|
function toNum(v, fallback) {
|
|
if (isNum(v)) return v;
|
|
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
|
return fallback;
|
|
}
|
|
const coord = (v) => round2(clamp(toNum(v, 0), -MAX_COORD, MAX_COORD));
|
|
|
|
export function newId(prefix = "e") {
|
|
const chars = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
let s = prefix;
|
|
for (let i = 0; i < 7; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
|
return s;
|
|
}
|
|
|
|
export function uniqueId(prefix, taken) {
|
|
let id = newId(prefix);
|
|
while (taken.has(id)) id = newId(prefix);
|
|
taken.add(id);
|
|
return id;
|
|
}
|
|
|
|
export function idPrefix(type) {
|
|
return ID_PREFIX[type] || "e";
|
|
}
|
|
|
|
const TYPE_ALIASES = {
|
|
rectangle: "rect", box: "rect", square: "rect", process: "rect", node: "rect", shape: "rect", card: "rect", step: "rect",
|
|
circle: "ellipse", oval: "ellipse", terminal: "ellipse", pill: "ellipse",
|
|
decision: "diamond", rhombus: "diamond", condition: "diamond", choice: "diamond",
|
|
database: "cylinder", db: "cylinder", storage: "cylinder", datastore: "cylinder",
|
|
label: "text", note: "text", title: "text",
|
|
line: "arrow", edge: "arrow", connector: "arrow", link: "arrow",
|
|
freehand: "pen", stroke: "pen", sketch: "pen",
|
|
};
|
|
|
|
export function resolveType(raw) {
|
|
let t = lower(raw?.type);
|
|
if ((!t || t === "shape" || t === "node") && raw?.shape) t = lower(raw.shape);
|
|
t = TYPE_ALIASES[t] || t;
|
|
return ELEMENT_TYPES.includes(t) ? t : null;
|
|
}
|
|
|
|
export function resolveShapeType(value, fallback = "rect") {
|
|
const t = TYPE_ALIASES[lower(value)] || lower(value);
|
|
return SHAPE_TYPES.includes(t) ? t : fallback;
|
|
}
|
|
|
|
const COLOR_ALIASES = {
|
|
grey: "gray", black: "gray", white: "gray", default: "gray", neutral: "gray", none: "gray",
|
|
cyan: "blue", navy: "blue", sky: "blue", teal: "green", lime: "green", emerald: "green",
|
|
amber: "yellow", gold: "yellow", violet: "purple", indigo: "purple", lavender: "purple",
|
|
magenta: "pink", rose: "pink", crimson: "red", brown: "orange",
|
|
};
|
|
|
|
export function normalizeColor(value, fallback = "gray") {
|
|
const s = lower(value);
|
|
if (!s) return fallback;
|
|
if (COLORS.includes(s)) return s;
|
|
if (COLOR_ALIASES[s]) return COLOR_ALIASES[s];
|
|
if (/^#[0-9a-f]{6}$/.test(s)) return s;
|
|
if (/^#[0-9a-f]{3}$/.test(s)) return "#" + [...s.slice(1)].map((c) => c + c).join("");
|
|
return fallback;
|
|
}
|
|
|
|
export function normalizeFill(value, fallback = "soft") {
|
|
if (value === true) return "soft";
|
|
if (value === false) return "none";
|
|
const s = lower(value);
|
|
if (FILLS.includes(s)) return s;
|
|
const alias = {
|
|
outline: "none", transparent: "none", empty: "none", hollow: "none",
|
|
light: "soft", tint: "soft", tinted: "soft", pastel: "soft",
|
|
filled: "solid", full: "solid", strong: "solid", bold: "solid", dark: "solid",
|
|
};
|
|
return alias[s] || fallback;
|
|
}
|
|
|
|
export function normalizeHead(value, fallback = "end") {
|
|
if (value === false) return "none";
|
|
if (value === true) return "end";
|
|
const s = lower(value);
|
|
if (HEADS.includes(s)) return s;
|
|
const alias = {
|
|
arrow: "end", forward: "end", to: "end", back: "start", backward: "start", reverse: "start", from: "start",
|
|
"two-way": "both", bidirectional: "both", double: "both", line: "none", no: "none", off: "none", plain: "none",
|
|
};
|
|
return alias[s] || fallback;
|
|
}
|
|
|
|
export function normalizeRoute(value, fallback = "straight") {
|
|
const s = lower(value);
|
|
if (ROUTES.includes(s)) return s;
|
|
const alias = {
|
|
direct: "straight", line: "straight", linear: "straight",
|
|
orthogonal: "elbow", step: "elbow", elbowed: "elbow", manhattan: "elbow", angled: "elbow",
|
|
curved: "curve", bezier: "curve", spline: "curve", smooth: "curve", arc: "curve",
|
|
};
|
|
return alias[s] || fallback;
|
|
}
|
|
|
|
export function normalizeSize(value, fallback = "m") {
|
|
if (isNum(value)) {
|
|
let best = fallback;
|
|
let diff = Infinity;
|
|
for (const k of SIZE_KEYS) {
|
|
const d = Math.abs(FONT_SIZES[k] - value);
|
|
if (d < diff) { diff = d; best = k; }
|
|
}
|
|
return best;
|
|
}
|
|
const s = lower(value);
|
|
if (SIZE_KEYS.includes(s)) return s;
|
|
const alias = { small: "s", sm: "s", medium: "m", md: "m", normal: "m", large: "l", lg: "l", big: "l", xlarge: "xl", "x-large": "xl", huge: "xl" };
|
|
return alias[s] || fallback;
|
|
}
|
|
|
|
export function normalizeText(value) {
|
|
if (typeof value === "number") return String(value);
|
|
if (typeof value !== "string") return "";
|
|
// eslint-disable-next-line no-control-regex
|
|
return value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 4000);
|
|
}
|
|
|
|
function refId(value) {
|
|
return typeof value === "string" && ID_PATTERN.test(value) ? value : null;
|
|
}
|
|
|
|
// A stroke with more than `max` points keeps an even spread of them, first and last included, so
|
|
// it loses detail rather than its end.
|
|
export function limitPoints(points, max = MAX_PEN_POINTS) {
|
|
if (points.length <= max) return points;
|
|
const step = (points.length - 1) / (max - 1);
|
|
return Array.from({ length: max }, (_, i) => points[Math.round(i * step)]);
|
|
}
|
|
|
|
function normalizePoints(points) {
|
|
if (!Array.isArray(points)) return [];
|
|
const out = [];
|
|
for (const p of limitPoints(points)) {
|
|
const nx = toNum(Array.isArray(p) ? p[0] : p?.x, NaN);
|
|
const ny = toNum(Array.isArray(p) ? p[1] : p?.y, NaN);
|
|
if (Number.isFinite(nx) && Number.isFinite(ny)) {
|
|
out.push([Math.round(clamp(nx, -MAX_COORD, MAX_COORD) * 10) / 10, Math.round(clamp(ny, -MAX_COORD, MAX_COORD) * 10) / 10]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Turns loose input (from the editor, a file or the agent) into a clean element, or null.
|
|
export function normalizeElement(raw) {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const type = resolveType(raw);
|
|
if (!type) return null;
|
|
const id = typeof raw.id === "string" && ID_PATTERN.test(raw.id) ? raw.id : newId(ID_PREFIX[type]);
|
|
const color = normalizeColor(raw.color);
|
|
const dash = Boolean(raw.dash ?? raw.dashed);
|
|
if (isShape({ type })) {
|
|
const [dw, dh] = DEFAULT_SIZES[type];
|
|
return {
|
|
id, type,
|
|
x: coord(raw.x), y: coord(raw.y),
|
|
w: round2(clamp(toNum(raw.w ?? raw.width, dw), 10, MAX_SIDE)),
|
|
h: round2(clamp(toNum(raw.h ?? raw.height, dh), 10, MAX_SIDE)),
|
|
text: normalizeText(raw.text ?? raw.label),
|
|
color, fill: normalizeFill(raw.fill), dash, size: normalizeSize(raw.size ?? raw.fontSize),
|
|
};
|
|
}
|
|
if (type === "text") {
|
|
return { id, type, x: coord(raw.x), y: coord(raw.y), text: normalizeText(raw.text ?? raw.label), color, size: normalizeSize(raw.size ?? raw.fontSize) };
|
|
}
|
|
if (type === "arrow") {
|
|
return {
|
|
id, type,
|
|
from: refId(raw.from ?? raw.source), to: refId(raw.to ?? raw.target),
|
|
x1: coord(raw.x1), y1: coord(raw.y1), x2: coord(raw.x2), y2: coord(raw.y2),
|
|
text: normalizeText(raw.text ?? raw.label),
|
|
color, dash,
|
|
head: normalizeHead(raw.head, lower(raw.type) === "line" ? "none" : "end"),
|
|
route: normalizeRoute(raw.route),
|
|
size: normalizeSize(raw.size ?? raw.fontSize, "s"),
|
|
};
|
|
}
|
|
const points = normalizePoints(raw.points);
|
|
if (!points.length) return null;
|
|
return { id, type: "pen", points, color, width: round2(clamp(toNum(raw.width ?? raw.strokeWidth, PEN_WIDTHS.m), 0.5, 24)) };
|
|
}
|
|
|
|
// Normalizes a whole list: fixes duplicate ids and drops arrows that point at missing shapes.
|
|
// Anything past `limit` elements is dropped.
|
|
export function normalizeElements(list, limit = MAX_ELEMENTS) {
|
|
const out = [];
|
|
const taken = new Set();
|
|
for (const raw of Array.isArray(list) ? list.slice(0, limit) : []) {
|
|
const el = normalizeElement(raw);
|
|
if (!el) continue;
|
|
if (taken.has(el.id)) el.id = uniqueId(ID_PREFIX[el.type], taken);
|
|
taken.add(el.id);
|
|
out.push(el);
|
|
}
|
|
const shapes = new Set(out.filter(isShape).map((e) => e.id));
|
|
return out.filter((el) => {
|
|
if (el.type !== "arrow") return true;
|
|
if (el.from && !shapes.has(el.from)) return false;
|
|
if (el.to && !shapes.has(el.to)) return false;
|
|
if (el.from && el.from === el.to) return false;
|
|
if (!el.from && !el.to && Math.hypot(el.x2 - el.x1, el.y2 - el.y1) < 1) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Merges element-level changes: { upserts: [element], deletes: [id], order?: [id] }.
|
|
export function applyOps(elements, ops = {}, limit = MAX_ELEMENTS) {
|
|
return normalizeElements(mergeOps(elements, ops), limit);
|
|
}
|
|
|
|
// The merge step of applyOps, before the list is cleaned up.
|
|
export function mergeOps(elements, ops = {}) {
|
|
const map = new Map(elements.map((e) => [e.id, e]));
|
|
for (const id of Array.isArray(ops.deletes) ? ops.deletes : []) map.delete(id);
|
|
for (const raw of Array.isArray(ops.upserts) ? ops.upserts : []) {
|
|
const el = normalizeElement(raw);
|
|
if (el) map.set(el.id, el);
|
|
}
|
|
let list = [...map.values()];
|
|
if (Array.isArray(ops.order) && ops.order.length) {
|
|
const rank = new Map(ops.order.map((id, i) => [id, i]));
|
|
const known = list.filter((e) => rank.has(e.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
|
|
list = [...known, ...list.filter((e) => !rank.has(e.id))];
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// Whether two JSON values hold the same data, whatever order their keys are in.
|
|
export function sameJson(a, b) {
|
|
if (a === b) return true;
|
|
if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) if (!sameJson(a[i], b[i])) return false;
|
|
return true;
|
|
}
|
|
const keys = Object.keys(a);
|
|
if (keys.length !== Object.keys(b).length) return false;
|
|
for (const k of keys) if (!Object.hasOwn(b, k) || !sameJson(a[k], b[k])) return false;
|
|
return true;
|
|
}
|
|
|
|
// The ops that turn the elements in `before` (a Map by id) into `elements`, as mergeOps applies
|
|
// them, or null when nothing changed.
|
|
export function diffOps(before, elements) {
|
|
const upserts = [];
|
|
const ids = new Set();
|
|
for (const el of elements) {
|
|
ids.add(el.id);
|
|
const prev = before.get(el.id);
|
|
if (!prev || !sameJson(prev, el)) upserts.push(el);
|
|
}
|
|
const deletes = [...before.keys()].filter((id) => !ids.has(id));
|
|
// mergeOps keeps existing ids in place and appends new ones, so order is only sent when that differs.
|
|
const mergedOrder = [...before.keys()].filter((id) => ids.has(id));
|
|
for (const el of elements) if (!before.has(el.id)) mergedOrder.push(el.id);
|
|
const orderChanged = mergedOrder.some((id, i) => id !== elements[i].id);
|
|
if (!upserts.length && !deletes.length && !orderChanged) return null;
|
|
const ops = { upserts, deletes };
|
|
if (orderChanged) ops.order = elements.map((e) => e.id);
|
|
return ops;
|
|
}
|
|
|
|
// Removes elements plus any arrows attached to them.
|
|
export function removeWithArrows(elements, ids) {
|
|
const del = new Set(ids);
|
|
for (const el of elements) if (el.type === "arrow" && (del.has(el.from) || del.has(el.to))) del.add(el.id);
|
|
return {
|
|
elements: elements.filter((e) => !del.has(e.id)),
|
|
removed: elements.filter((e) => del.has(e.id)).map((e) => e.id),
|
|
};
|
|
}
|
|
|
|
// ---------- colors ----------
|
|
|
|
export const LIGHT_PALETTE = {
|
|
bg: "#ffffff", ink: "#1f2328",
|
|
blue: "#0969da", green: "#1a7f37", yellow: "#9a6700", orange: "#bc4c00",
|
|
red: "#cf222e", purple: "#8250df", pink: "#bf3989",
|
|
};
|
|
export const DARK_PALETTE = {
|
|
bg: "#0d1117", ink: "#e6edf3",
|
|
blue: "#4493f8", green: "#3fb950", yellow: "#d29922", orange: "#db6d28",
|
|
red: "#f85149", purple: "#ab7df8", pink: "#db61a2",
|
|
};
|
|
|
|
function hexToRgb(hex) {
|
|
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
|
|
if (!m) return [0, 0, 0];
|
|
const n = parseInt(m[1], 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
const toHex = (rgb) => "#" + rgb.map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join("");
|
|
|
|
export function mixHex(a, b, t) {
|
|
const ca = hexToRgb(a);
|
|
const cb = hexToRgb(b);
|
|
return toHex(ca.map((v, i) => v * t + cb[i] * (1 - t)));
|
|
}
|
|
|
|
export function luminance(hex) {
|
|
const [r, g, b] = hexToRgb(hex).map((v) => {
|
|
const c = v / 255;
|
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
// Text color for a solid background. Dark and white text have equal contrast at about 0.21.
|
|
export function textOn(hex) {
|
|
return luminance(hex) > 0.21 ? "#1f2328" : "#ffffff";
|
|
}
|
|
|
|
// Returns paint(color, role) resolving element colors to hex for a palette.
|
|
// Roles: stroke, soft (tinted fill), solid (strong fill), onSolid (text on solid), ink (text), bg.
|
|
export function staticPaint(palette = LIGHT_PALETTE) {
|
|
const pal = { ...LIGHT_PALETTE, ...palette };
|
|
const dark = luminance(pal.bg) < 0.4;
|
|
const base = (color) => (color === "gray" ? pal.ink : String(color).startsWith("#") ? color : pal[color] || pal.ink);
|
|
return (color, role) => {
|
|
const c = base(color);
|
|
switch (role) {
|
|
case "soft": return mixHex(c, pal.bg, color === "gray" ? (dark ? 0.12 : 0.06) : (dark ? 0.24 : 0.14));
|
|
case "onSolid": return textOn(c);
|
|
case "bg": return pal.bg;
|
|
default: return c;
|
|
}
|
|
};
|
|
}
|