quick-erd
Version:
quick and easy text-based ERD + code generator for migration, query, typescript types and orm entity
1,457 lines (1,447 loc) • 107 kB
JavaScript
"use strict";
(() => {
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
// src/core/enum.ts
function formatEnum(type) {
return type.replace(/\(/g, "('").replace(/\)/g, "')").replace(/ ?, ?/g, "','").replace(/''/g, "'");
}
var init_enum = __esm({
"src/core/enum.ts"() {
"use strict";
}
});
// src/core/meta.ts
function zoomToLine(zoom) {
return `# zoom: ${zoom.toFixed(3)}`;
}
function viewToLine(view) {
const x = view.x.toFixed(0);
const y = view.y.toFixed(0);
return `# view: (${x}, ${y})`;
}
function tableNameToRegex(name) {
return new RegExp(`# ${name} \\([0-9-]+, [0-9-]+[, #0-9a-f]*\\)`);
}
function tableNameToLine(name, position) {
const x = position.x.toFixed(0);
const y = position.y.toFixed(0);
return position.color ? `# ${name} (${x}, ${y}, ${position.color})` : `# ${name} (${x}, ${y})`;
}
function textBgColorToLine(color) {
return `# text-bg: ${color}`;
}
function textColorToLine(color) {
return `# text-color: ${color}`;
}
function diagramBgColorToLine(color) {
return `# diagram-bg: ${color}`;
}
function diagramTextColorToLine(color) {
return `# diagram-text: ${color}`;
}
function tableBgColorToLine(color) {
return `# table-bg: ${color}`;
}
function tableTextColorToLine(color) {
return `# table-text: ${color}`;
}
var zoomLineRegex, zoomValueRegex, viewLineRegex, viewPositionRegex, tableNameRegex, tableNameRegex_g, textBgColorRegex, textColorRegex, diagramBgColorRegex, diagramTextColorRegex, tableBgColorRegex, tableTextColorRegex, colors;
var init_meta = __esm({
"src/core/meta.ts"() {
"use strict";
zoomLineRegex = /# zoom: [0-9.]+/;
zoomValueRegex = /# zoom: ([0-9.]+)/;
viewLineRegex = /# view: \([0-9-]+, [0-9-]+\)/;
viewPositionRegex = /# view: \(([0-9-.]+), ([0-9-.]+)\)/;
tableNameRegex = /# (\w+) \(([0-9-]+), ([0-9-]+),? ?(#[0-9a-f]+)?\)/;
tableNameRegex_g = new RegExp(
tableNameRegex.toString().slice(1, -1),
"g"
);
textBgColorRegex = /# text-bg: (#\w+)/;
textColorRegex = /# text-color: (#\w+)/;
diagramBgColorRegex = /# diagram-bg: (#\w+)/;
diagramTextColorRegex = /# diagram-text: (#\w+)/;
tableBgColorRegex = /# table-bg: (#\w+)/;
tableTextColorRegex = /# table-text: (#\w+)/;
colors = {
textBgColor: { regex: textBgColorRegex, toLine: textBgColorToLine },
textColor: { regex: textColorRegex, toLine: textColorToLine },
diagramBgColor: { regex: diagramBgColorRegex, toLine: diagramBgColorToLine },
diagramTextColor: {
regex: diagramTextColorRegex,
toLine: diagramTextColorToLine
},
tableBgColor: { regex: tableBgColorRegex, toLine: tableBgColorToLine },
tableTextColor: { regex: tableTextColorRegex, toLine: tableTextColorToLine }
};
}
});
// src/core/ast.ts
function parse(input) {
const parser = new Parser();
parser.parse(input);
return parser;
}
function parseAll(fn) {
const result_list = [];
for (; ; ) {
try {
result_list.push(fn());
} catch (error) {
return result_list;
}
}
}
var Parser, NonEmptyLineError, LineError, ParseNameError, ParseRelationTypeError, ParseForeignKeyReferenceError, defaultFieldType, defaultRelationType;
var init_ast = __esm({
"src/core/ast.ts"() {
"use strict";
init_enum();
init_meta();
Parser = class {
table_list = [];
line_list = [];
zoom;
view;
textBgColor;
diagramTextColor;
textColor;
diagramBgColor;
tableBgColor;
tableTextColor;
parse(input) {
input.split("\n").forEach((line) => {
line = line.trim().replace(/#.*/, "").replace(/\/\/.*/, "").trim();
if (!line) return;
this.line_list.push(line);
});
this.table_list = [];
while (this.hasTable()) {
this.table_list.push(this.parseTable());
}
this.parseMeta(input);
}
parseMeta(input) {
const zoom = +input.match(zoomValueRegex)?.[1];
if (zoom) this.zoom = zoom;
const view = input.match(viewPositionRegex);
if (view) this.view = { x: +view[1], y: +view[2] };
const textBgColor = input.match(textBgColorRegex);
if (textBgColor) this.textBgColor = textBgColor[1];
const textColor = input.match(textColorRegex);
if (textColor) this.textColor = textColor[1];
const diagramBgColor = input.match(diagramBgColorRegex);
if (diagramBgColor) this.diagramBgColor = diagramBgColor[1];
const diagramTextColor = input.match(diagramTextColorRegex);
if (diagramTextColor) this.diagramTextColor = diagramTextColor[1];
const tableBgColor = input.match(tableBgColorRegex);
if (tableBgColor) this.tableBgColor = tableBgColor[1];
const tableTextColor = input.match(tableTextColorRegex);
if (tableTextColor) this.tableTextColor = tableTextColor[1];
input.match(tableNameRegex_g)?.forEach((line) => {
const match = line.match(tableNameRegex) || [];
const name = match[1];
const x = +match[2];
const y = +match[3];
const color = match[4];
const table = this.table_list.find((table2) => table2.name == name);
if (table) table.position = { x, y, color };
});
}
peekLine() {
if (this.line_list.length === 0) {
throw new Error("no reminding line");
}
return this.line_list[0];
}
hasTable() {
while (this.line_list[0] === "") this.line_list.shift();
return this.line_list[0] && this.line_list[1]?.startsWith("-");
}
parseTable() {
const name = this.parseName();
this.parseEmptyLine();
this.skipLine("-");
const field_list = parseAll(() => {
if (this.hasTable()) {
throw new Error("end of table");
}
return this.parseField();
});
const has_primary_key = field_list.some((field) => field.is_primary_key);
if (!has_primary_key) {
const field = field_list.find((field2) => field2.name === "id");
if (field) {
field.is_primary_key = true;
}
}
return { name, field_list };
}
parseField() {
const field_name = this.parseName();
let type = "";
let is_null = false;
let is_unique = false;
let is_primary_key = false;
let is_unsigned = false;
let default_value;
let references;
for (; ; ) {
const name = this.parseType();
if (!name) break;
switch (name.toUpperCase()) {
case "NULL":
is_null = true;
continue;
case "UNIQUE":
is_unique = true;
continue;
case "UNSIGNED":
is_unsigned = true;
continue;
case "DEFAULT":
default_value = this.parseDefaultValue();
continue;
case "PK":
is_primary_key = true;
continue;
case "FK":
references = this.parseForeignKeyReference(field_name);
continue;
default:
if (type) {
console.debug("unexpected token:", {
field_name,
type,
token: name
});
continue;
}
type = name;
}
}
type ||= defaultFieldType;
this.skipLine();
return {
name: field_name,
type,
is_null,
is_unique,
is_primary_key,
is_unsigned,
default_value,
references
};
}
skipLine(line = "") {
if (this.line_list[0]?.startsWith(line)) {
this.line_list.shift();
}
}
parseEmptyLine() {
const line = this.line_list[0]?.trim();
if (line !== "") {
throw new NonEmptyLineError(line);
}
this.line_list.shift();
}
parseName() {
let line = this.peekLine();
const match = line.match(/[a-zA-Z0-9_]+/);
if (!match) {
throw new ParseNameError(line);
}
const name = match[0];
line = line.replace(name, "").trim();
this.line_list[0] = line;
return name;
}
parseType() {
let line = this.peekLine();
let match = line.match(/^not null\s*/i);
if (match) {
line = line.slice(match[0].length);
}
match = line.match(/^\w+\(.*?\)/) || line.match(/^[a-zA-Z0-9_(),"']+/);
if (!match) {
return;
}
const name = match[0];
line = line.replace(name, "").trim();
this.line_list[0] = line;
if (name.match(/^enum/i)) {
return formatEnum(name);
}
return name;
}
parseDefaultValue() {
let line = this.peekLine();
let end;
if (line[0] === '"') {
end = line.indexOf('"', 1) + 1;
} else if (line[0] === "'") {
end = line.indexOf("'", 1) + 1;
} else if (line[0] === "`") {
end = line.indexOf("`", 1) + 1;
} else if (line.includes(" ")) {
end = line.indexOf(" ");
} else {
end = line.length - 1;
}
const value = line.slice(0, end + 1);
line = line.replace(value, "").trim();
this.line_list[0] = line;
return value;
}
parseRelationType() {
let line = this.peekLine();
const match = line.match(/.* /);
if (!match) {
throw new ParseRelationTypeError(line);
}
const type = match[0].trim();
line = line.replace(match[0], "").trim();
this.line_list[0] = line;
return type;
}
parseForeignKeyReference(ref_field_name) {
if (ref_field_name.endsWith("_id") && this.peekLine() === "") {
return {
table: ref_field_name.replace(/_id$/, ""),
field: "id",
type: defaultRelationType
};
}
const type = this.parseRelationType();
const table = this.parseName();
const line = this.peekLine();
let field;
if (line == "") {
field = "id";
} else if (line.startsWith(".")) {
this.line_list[0] = line.slice(1);
field = this.parseName();
} else {
throw new ParseForeignKeyReferenceError(line);
}
return { type, table, field };
}
};
NonEmptyLineError = class extends Error {
};
LineError = class extends Error {
constructor(line, message) {
super(message);
this.line = line;
}
};
ParseNameError = class extends LineError {
};
ParseRelationTypeError = class extends LineError {
};
ParseForeignKeyReferenceError = class extends LineError {
constructor(line) {
super(line, `expect '.', got '${line[0]}'`);
this.line = line;
}
};
defaultFieldType = "integer";
defaultRelationType = ">0-";
}
});
// src/core/guide.ts
function makeGuide(origin) {
return `
# Visualize on ${origin}
#
# Relationship Types
# - - one to one
# -< - one to many
# >- - many to one
# >-< - many to many
# -0 - one to zero or one
# 0- - zero or one to one
# 0-0 - zero or one to zero or one
# -0< - one to zero or many
# >0- - zero or many to one
#
////////////////////////////////////
`.trim();
}
var init_guide = __esm({
"src/core/guide.ts"() {
"use strict";
}
});
// src/core/table.ts
function tableToString(table) {
return `
${table.name}
${"-".repeat(table.name.length)}
${table.field_list.map(fieldToString).join("\n")}
`;
}
function fieldToString(field) {
let type = field.type;
if (type.match(/^enum/i)) {
type = formatEnum(type);
}
let text = `${field.name} ${type}`;
if (field.is_unsigned) {
text += ` unsigned`;
}
if (field.is_null) {
text += " NULL";
}
if (field.is_unique) {
text += " unique";
}
if (field.is_primary_key) {
text += " PK";
}
if (field.references) {
const ref = field.references;
text += ` FK ${ref.type} ${ref.table}.${ref.field}`;
}
return text;
}
function astToText(ast) {
let text = "";
text += makeGuide(
"https://erd.surge.sh or https://quick-erd.surge.sh"
).replace(" or ", "\n# or ");
for (const table of ast.table_list) {
text += "\n\n\n" + tableToString(table).trim();
}
text += "\n\n";
if (ast.zoom) {
text += "\n" + zoomToLine(ast.zoom);
}
if (ast.view) {
text += "\n" + viewToLine(ast.view);
}
if (ast.textBgColor) {
text += "\n" + textBgColorToLine(ast.textBgColor);
}
if (ast.textColor) {
text += "\n" + textColorToLine(ast.textColor);
}
if (ast.diagramBgColor) {
text += "\n" + diagramBgColorToLine(ast.diagramBgColor);
}
if (ast.diagramTextColor) {
text += "\n" + diagramTextColorToLine(ast.diagramTextColor);
}
if (ast.tableBgColor) {
text += "\n" + tableBgColorToLine(ast.tableBgColor);
}
if (ast.tableTextColor) {
text += "\n" + tableTextColorToLine(ast.tableTextColor);
}
for (const table of ast.table_list) {
if (table.position) {
text += "\n" + tableNameToLine(table.name, table.position);
}
}
return text.trim();
}
var init_table = __esm({
"src/core/table.ts"() {
"use strict";
init_enum();
init_guide();
init_meta();
}
});
// node_modules/.pnpm/oklab.ts@2.2.7/node_modules/oklab.ts/dist/oklab.ts
function new_oklab() {
return { L: 0, a: 0, b: 0 };
}
function rgb_to_oklab(c, o) {
if (!o) {
o = new_oklab();
rgb_to_oklab(c, o);
return o;
}
const r = gamma_inv(c.r / 255);
const g = gamma_inv(c.g / 255);
const b = gamma_inv(c.b / 255);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
o.L = l * 0.2104542553 + m * 0.793617785 + s * -0.0040720468;
o.a = l * 1.9779984951 + m * -2.428592205 + s * 0.4505937099;
o.b = l * 0.0259040371 + m * 0.7827717662 + s * -0.808675766;
}
function gamma_inv(x) {
return x >= 0.04045 ? Math.pow((x + 0.055) / (1 + 0.055), 2.4) : x / 12.92;
}
var range;
var init_oklab = __esm({
"node_modules/.pnpm/oklab.ts@2.2.7/node_modules/oklab.ts/dist/oklab.ts"() {
range = {
L: {
min: 0,
max: 0.9999999934735462,
range: 0.9999999934735462
},
a: {
min: -0.23388757418790818,
max: 0.27621639742350523,
range: 0.5101039716114134
},
b: {
min: -0.3115281476783751,
max: 0.19856975465179516,
range: 0.5100979023301703
}
};
}
});
// src/client/dom.ts
function querySelector(parent, selector) {
const element = parent.querySelector(selector);
if (!element) {
throw new Error("Element not found, selector: " + selector);
}
return element;
}
var init_dom = __esm({
"src/client/dom.ts"() {
"use strict";
}
});
// src/client/color.ts
function decodeColor(color, defaultColor) {
let match = color.match(/^#\w+$/);
if (match) return color;
match = color.match(/^rgb\((\w+)\s*,\s*(\w+)\s*,\s*(\w+)\)$/);
if (match) {
return "#" + toHex(match[1]) + toHex(match[2]) + toHex(match[3]);
}
const span = document.createElement("span");
span.style.color = defaultColor;
span.style.display = "none";
document.body.appendChild(span);
const s = getComputedStyle(span).color;
span.remove();
return decodeColor(s, defaultColor);
}
function toHex(int) {
const hex = (+int).toString(16);
if (hex.length == 1) {
return "0" + hex;
}
return hex;
}
function randomDimHex() {
return toHex(floor(random() * 100));
}
function randomBrightHex() {
return toHex(floor(256 - random() * 100));
}
function randomDimColor() {
const r = randomDimHex();
const g = randomDimHex();
const b = randomDimHex();
return "#" + r + g + b;
}
function randomBrightColor() {
const r = randomBrightHex();
const g = randomBrightHex();
const b = randomBrightHex();
return "#" + r + g + b;
}
function calcTextColor(backgroundColorHex) {
const r = parseInt(backgroundColorHex.slice(1, 3), 16);
const g = parseInt(backgroundColorHex.slice(3, 5), 16);
const b = parseInt(backgroundColorHex.slice(5, 7), 16);
const oklab = rgb_to_oklab({ r, g, b });
const l = (oklab.L - range.L.min) / range.L.range;
return l < 0.75 ? "#fff" : "#000";
}
var random, floor, ColorController, ColorInput;
var init_color = __esm({
"src/client/color.ts"() {
"use strict";
init_oklab();
init_dom();
({ random, floor } = Math);
ColorController = class {
constructor(root, targets, inputController) {
this.root = root;
this.targets = targets;
this.inputController = inputController;
this.textBgColor = new ColorInput(this.root, "text-bg-color", {
getEffectiveColor: () => getComputedStyle(this.targets.editor).backgroundColor,
onColorChanged: (color) => this.inputController.setColor("textBgColor", color)
});
this.textColor = new ColorInput(this.root, "text-color", {
getEffectiveColor: () => getComputedStyle(this.targets.input).color,
onColorChanged: (color) => this.inputController.setColor("textColor", color)
});
this.diagramBgColor = new ColorInput(this.root, "diagram-bg-color", {
getEffectiveColor: () => getComputedStyle(this.targets.diagram).backgroundColor,
onColorChanged: (color) => this.inputController.setColor("diagramBgColor", color)
});
this.diagramTextColor = new ColorInput(this.root, "diagram-text-color", {
getEffectiveColor: () => getComputedStyle(this.targets.diagram).color,
onColorChanged: (color) => this.inputController.setColor("diagramTextColor", color)
});
this.tableBgColor = new ColorInput(this.root, "table-bg-color", {
getEffectiveColor: () => getComputedStyle(this.targets.tableStub).backgroundColor,
onColorChanged: (color) => this.inputController.setColor("tableBgColor", color)
});
this.tableTextColor = new ColorInput(this.root, "table-text-color", {
getEffectiveColor: () => getComputedStyle(this.targets.tableStub).color,
onColorChanged: (color) => this.inputController.setColor("tableTextColor", color)
});
this.inputs = [
this.textBgColor,
this.textColor,
this.diagramBgColor,
this.diagramTextColor,
this.tableBgColor,
this.tableTextColor
];
this.initInputValues();
}
textBgColor;
textColor;
diagramBgColor;
diagramTextColor;
tableBgColor;
tableTextColor;
inputs;
resetColors() {
for (const input of this.inputs) {
input.reset();
}
}
initInputValues() {
for (const input of this.inputs) {
input.initInputValue();
}
}
flushToInputController() {
for (const input of this.inputs) {
input.flushToInputController();
}
}
randomTitleBgColor() {
const { r, g, b } = this.tableBgColor.valueAsRGB;
const mean = (r + g + b) / 3;
return mean < 127 ? randomBrightColor() : randomDimColor();
}
};
ColorInput = class {
constructor(root, name, io) {
this.root = root;
this.io = io;
this.propertyName = "--" + name;
this.defaultColor = this.root.style.getPropertyValue(this.propertyName);
this.input = querySelector(this.root, "#" + name);
this.input.addEventListener("input", () => {
this.setCSSVariable(this.input.value);
this.io.onColorChanged(this.input.value);
});
}
defaultColor;
propertyName;
input;
get valueAsRGB() {
const color = decodeColor(this.input.value, this.defaultColor);
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return { r, g, b };
}
flushToInputController() {
this.io.onColorChanged(this.input.value);
}
setCSSVariable(color) {
this.root.style.setProperty(this.propertyName, color);
}
applyParsedColor(color) {
this.input.value = decodeColor(color, this.defaultColor);
this.setCSSVariable(this.input.value);
}
reset() {
this.setCSSVariable(this.defaultColor);
this.initInputValue();
}
initInputValue() {
this.input.value = decodeColor(
this.io.getEffectiveColor(),
this.defaultColor
);
}
};
}
});
// src/client/storage.ts
function cleanStorage() {
for (let i = localStorage.length; i >= 0; i--) {
const key = localStorage.key(i);
if (key && localStorage.getItem(key) == "0") {
delete localStorage[key];
}
}
}
var StoredValue, StoredString, StoredBoolean, StoredNumber;
var init_storage = __esm({
"src/client/storage.ts"() {
"use strict";
StoredValue = class {
constructor(key, defaultValue) {
this.key = key;
this.defaultValue = defaultValue;
const text = localStorage.getItem(key);
this._value = text == null ? defaultValue : this.decode(text);
}
_value;
watch(cb) {
window.addEventListener("storage", (event) => {
if (event.storageArea != localStorage || event.key != this.key) return;
const value = event.newValue == null ? this.defaultValue : this.decode(event.newValue);
if (this._value == value) return;
this.value = value;
cb(value);
});
}
// value is auto persisted
set value(value) {
if (value == this._value) return;
this._value = value;
this.save();
}
get value() {
return this._value;
}
// quick value is not persisted, intended to be saved in batch
get quickValue() {
return this._value;
}
set quickValue(value) {
this._value = value;
}
save() {
localStorage.setItem(this.key, this.encode(this._value));
}
remove() {
localStorage.removeItem(this.key);
}
toString() {
return this.encode(this._value);
}
};
StoredString = class extends StoredValue {
encode(value) {
return value;
}
decode(text) {
return text;
}
};
StoredBoolean = class extends StoredValue {
encode(value) {
return value ? "true" : "false";
}
decode(text) {
return text == "true" ? true : text == "false" ? false : this.defaultValue;
}
};
StoredNumber = class extends StoredValue {
encode(value) {
return value.toString();
}
decode(text) {
const number = parseFloat(text);
return Number.isNaN(number) ? this.defaultValue : number;
}
};
}
});
// src/client/diagram.ts
function isPointInside(rect, x, y) {
return rect.left <= x && x <= rect.right && rect.top <= y && y <= rect.bottom;
}
function renderRelationBar({
path,
from_x: f_x,
from_y: f_y,
border_x: b_x,
barRadius,
type
}) {
const a_x = b_x - (b_x - f_x) / 3;
const a_t = f_y - barRadius / 4;
const a_b = f_y + barRadius / 4;
switch (type) {
case "many":
path.setAttributeNS(
null,
"d",
`M ${a_x} ${f_y} L ${f_x} ${a_t} M ${a_x} ${f_y} L ${f_x} ${a_b}`
);
break;
case "one":
path.setAttributeNS(null, "d", `M ${a_x} ${a_t} V ${a_b}`);
break;
case "zero": {
const r = (a_t - a_b) / 3;
const x = b_x;
path.setAttributeNS(
null,
"d",
`M ${x} ${f_y} A ${r} ${r} 0 1 0 ${x} ${f_y - 1e-3 * sign(f_x - a_x)}`
);
break;
}
case "zero-or-many": {
const r = (a_t - a_b) / 5;
const x = b_x;
path.setAttributeNS(
null,
"d",
`M ${x} ${f_y} A ${r} ${r} 0 1 0 ${x} ${f_y - 1e-3 * sign(f_x - a_x)}
M ${a_x} ${f_y} L ${f_x} ${a_t}
M ${a_x} ${f_y} L ${f_x} ${a_b}
M ${a_x} ${f_y} L ${f_x} ${f_y}
`
);
break;
}
default:
path.setAttributeNS(null, "d", ``);
}
}
var abs, sign, min, max, DiagramController, TablesContainer, TableController, LineController;
var init_diagram = __esm({
"src/client/diagram.ts"() {
"use strict";
init_color();
init_dom();
init_storage();
({ abs, sign, min, max } = Math);
DiagramController = class {
constructor(div, inputController, colorController, queryController, zoomLevel, view) {
this.div = div;
this.inputController = inputController;
this.colorController = colorController;
this.queryController = queryController;
this.barRadius = this.calcBarRadius();
this.fontSizeSpan = this.querySelector("#font-size");
this.message = this.querySelector(".message");
this.tablesContainer = new TablesContainer(
this,
this.querySelector("#tables-container"),
view
);
this.controls = this.querySelector(".controls");
this.zoom = zoomLevel;
this.div.addEventListener("mousemove", (ev) => {
if (this.onMouseMove) {
this.onMouseMove(ev);
} else {
this.tablesContainer.onMouseMove(ev);
}
});
this.div.addEventListener("touchmove", (ev) => {
const e = ev.touches.item(0);
if (!e) return;
if (this.onMouseMove) {
this.onMouseMove(e);
} else {
this.tablesContainer.onMouseMove(e);
}
});
this.div.addEventListener("mouseup", () => {
delete this.onMouseMove;
});
this.div.addEventListener("touchend", () => {
delete this.onMouseMove;
});
this.controls.querySelector("#font-inc")?.addEventListener("click", () => this.fontInc());
this.controls.querySelector("#font-dec")?.addEventListener("click", () => this.fontDec());
this.applyFontSize();
}
fontSizeSpan;
message;
tablesContainer;
controls;
tableMap = /* @__PURE__ */ new Map();
maxZIndex = 0;
zoom;
barRadius;
isDetailMode = new StoredBoolean("is_detail_mode", true);
isAutoMoving = false;
getSafeZIndex() {
return (this.maxZIndex + 1) * 100;
}
onMouseMove;
querySelector(selector) {
return querySelector(this.div, selector);
}
remove(table) {
table.remove();
this.tableMap.delete(table.data.name);
}
getDiagramRect() {
const rect = this.div.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
width: this.div.scrollWidth,
height: this.div.scrollHeight
};
}
getNewTablePosition() {
const rect = this.getDiagramRect();
const view = {
x: this.tablesContainer.view.x.value,
y: this.tablesContainer.view.y.value
};
return {
x: (rect.right - rect.left) / 2 + view.x,
y: (rect.bottom - rect.top) / 2 + view.y
};
}
calcBarRadius() {
return +getComputedStyle(this.div).fontSize.replace("px", "") * 2.125;
}
add(table, diagramRect) {
const tableDiv = document.createElement("div");
tableDiv.dataset.table = table.name;
let isMouseDown = false;
let startX = 0;
let startY = 0;
const onMouseDown = (ev) => {
this.maxZIndex++;
tableDiv.style.zIndex = this.maxZIndex.toString();
isMouseDown = true;
startX = ev.clientX;
startY = ev.clientY;
this.onMouseMove = (ev2) => {
if (!isMouseDown) return;
controller.translateX.quickValue += ev2.clientX - startX;
controller.translateY.quickValue += ev2.clientY - startY;
startX = ev2.clientX;
startY = ev2.clientY;
controller.renderTransform(this.getDiagramRect());
};
};
tableDiv.addEventListener("mousedown", (ev) => {
onMouseDown(ev);
});
tableDiv.addEventListener("touchstart", (ev) => {
const e = ev.touches.item(0);
if (!e) return;
onMouseDown(e);
});
tableDiv.addEventListener("mouseup", () => {
isMouseDown = false;
});
tableDiv.addEventListener("touchend", () => {
isMouseDown = false;
});
this.tablesContainer.appendChild(tableDiv);
const controller = new TableController(this, tableDiv, table);
this.tableMap.set(table.name, controller);
controller.render(table);
controller.renderTransform(diagramRect);
return controller;
}
render({ table_list, view, zoom }) {
if (table_list.length === 0) {
this.message.style.display = "inline-block";
} else {
this.message.style.display = "none";
}
if (view) {
this.tablesContainer.view.x.value = view.x;
this.tablesContainer.view.y.value = view.y;
this.tablesContainer.renderTransform("skip_storage");
}
if (zoom) {
this.zoom.value = zoom;
this.applyFontSize("skip_storage");
}
const newTableMap = new Map(table_list.map((table) => [table.name, table]));
const removedTableControllers = [];
const newTableControllers = [];
this.tableMap.forEach((table, name) => {
if (!newTableMap.has(name)) {
this.remove(table);
removedTableControllers.push(table);
}
});
const diagramRect = this.getDiagramRect();
newTableMap.forEach((table, name) => {
let controller = this.tableMap.get(name);
if (controller) {
controller.render(table);
} else {
controller = this.add(table, diagramRect);
newTableControllers.push(controller);
}
});
if (removedTableControllers.length === 1 && newTableControllers.length === 1) {
this.renameTable(
removedTableControllers[0],
newTableControllers[0],
diagramRect
);
}
this.tableMap.forEach((table) => {
table.renderLine(diagramRect);
});
this.controls.style.zIndex = this.getSafeZIndex().toString();
}
renameTable(oldTableController, newTableController, diagramRect) {
newTableController.restoreMetadata(oldTableController);
newTableController.renderTransform(diagramRect);
const oldTableName = oldTableController.data.name;
const newTableName = newTableController.data.name;
this.queryController.renameTable(oldTableName, newTableName);
this.tableMap.forEach((table) => {
table.data.field_list.forEach((field) => {
if (field.references?.table === oldTableName && field.name === oldTableName + "_id") {
const newFieldName = newTableName + "_id";
this.inputController.renameField({
fromTable: table.data.name,
fromField: {
oldName: field.name,
newName: newFieldName
},
toTable: {
oldName: field.references.table,
newName: newTableName
}
});
table.renameField(field.name, newFieldName);
field.name = newFieldName;
field.references.table = newTableName;
table.render(table.data);
}
});
});
}
renderLines() {
const diagramRect = this.getDiagramRect();
this.tableMap.forEach((table) => {
table.renderLine(diagramRect);
});
}
autoPlace() {
this.isAutoMoving = !this.isAutoMoving;
if (!this.isAutoMoving) return;
const tables = [];
this.tableMap.forEach((table) => {
const rect = table.div.getBoundingClientRect();
tables.push({
table,
rect: {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
force: { x: 0, y: 0 },
speed: { x: 0, y: 0 }
}
});
});
const diagramRect = this.getDiagramRect();
const loop = () => {
if (!this.isAutoMoving) return;
let isMoved = false;
tables.forEach(({ table, rect }) => {
rect.force.y = rect.top < diagramRect.top ? 1 : rect.bottom > diagramRect.bottom ? -1 : 0;
rect.force.x = rect.left < diagramRect.left ? 1 : rect.right > diagramRect.right ? -1 : 0;
tables.forEach((other) => {
if (other.table === table) return;
if (isPointInside(other.rect, rect.left, rect.top)) {
rect.force.x += 1;
rect.force.y += 1;
}
if (isPointInside(other.rect, rect.right, rect.top)) {
rect.force.x -= 1;
rect.force.y += 1;
}
if (isPointInside(other.rect, rect.left, rect.bottom)) {
rect.force.x += 1;
rect.force.y -= 1;
}
if (isPointInside(other.rect, rect.right, rect.bottom)) {
rect.force.x -= 1;
rect.force.y -= 1;
}
});
rect.speed.x += rect.force.x;
rect.speed.y += rect.force.y;
const minSpeed = 1;
if (Math.abs(rect.speed.x) < minSpeed && Math.abs(rect.speed.y) < minSpeed) {
return;
}
table.translateX.quickValue += rect.speed.x;
table.translateY.quickValue += rect.speed.y;
table.quickRenderTransform(diagramRect);
isMoved = true;
rect.left += rect.speed.x;
rect.right += rect.speed.x;
rect.top += rect.speed.y;
rect.bottom += rect.speed.y;
rect.speed.x *= 0.95;
rect.speed.y *= 0.95;
});
if (!isMoved) {
this.isAutoMoving = false;
tables.forEach(({ table }) => table.saveTransform());
return;
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
toggleDetails() {
this.isDetailMode.value = !this.isDetailMode.value;
const diagramRect = this.getDiagramRect();
this.tableMap.forEach((table) => {
table.rerenderColumns();
table.renderLine(diagramRect);
});
}
applyFontSize(mode) {
const fontSize = this.zoom.value;
if (mode !== "skip_storage") {
this.inputController.setZoom(fontSize);
}
this.fontSizeSpan.textContent = (fontSize * 100).toFixed(0) + "%";
this.div.style.fontSize = fontSize + "em";
this.barRadius = this.calcBarRadius();
const diagramRect = this.getDiagramRect();
this.tableMap.forEach((table) => {
table.renderLine(diagramRect);
});
}
calcFontStep() {
return Math.min(this.zoom.value * 0.1, 0.25);
}
setFont(zoom) {
this.zoom.value = zoom;
this.applyFontSize();
}
fontInc() {
this.setFont(this.zoom.value + this.calcFontStep());
}
fontDec() {
this.setFont(this.zoom.value - this.calcFontStep());
}
fontReset() {
this.setFont(1);
}
resetView() {
this.isAutoMoving = false;
this.fontReset();
this.tablesContainer.resetView();
}
randomColor() {
this.tableMap.forEach((tableController) => {
tableController.randomColor();
});
}
resetColor() {
this.tableMap.forEach((tableController) => {
tableController.resetColor();
});
this.inputController.resetColor();
}
exportJSON(json) {
json.zoom = this.zoom.value;
this.tablesContainer.exportJSON(json);
this.tableMap.forEach((table) => {
table.exportJSON(json);
});
}
flushToInputController() {
this.inputController.setZoom(this.zoom.value);
this.inputController.setViewPosition({
x: this.tablesContainer.view.x.value,
y: this.tablesContainer.view.y.value
});
for (const [name, table] of this.tableMap) {
this.inputController.setTablePosition(name, {
x: table.translateX.value,
y: table.translateY.value,
color: table.color.value
});
}
}
getTableList() {
return Array.from(this.tableMap.values(), (table) => table.data);
}
applySelectedColumns(columns) {
const tableFields = /* @__PURE__ */ new Map();
columns.forEach((column) => {
const fields = tableFields.get(column.table);
if (fields) {
fields.push(column.field);
} else {
tableFields.set(column.table, [column.field]);
}
});
this.tableMap.forEach((table) => {
table.applySelectedFields(tableFields.get(table.data.name) || []);
});
}
};
TablesContainer = class {
constructor(diagram, div, view) {
this.diagram = diagram;
this.div = div;
this.view = view;
this.div.style.transform = `translate(${-view.x}px,${-view.y}px)`;
this.diagram.inputController.setViewPosition({
x: view.x.value,
y: view.y.value
});
let isMouseDown = false;
let startX = 0;
let startY = 0;
this.onMouseMove = (ev) => {
if (!isMouseDown) return;
view.x.value -= ev.clientX - startX;
view.y.value -= ev.clientY - startY;
startX = ev.clientX;
startY = ev.clientY;
this.renderTransform();
};
const onMouseDown = (ev) => {
isMouseDown = true;
startX = ev.clientX;
startY = ev.clientY;
};
const container = this.diagram.div;
container.addEventListener("mousedown", (ev) => {
onMouseDown(ev);
});
container.addEventListener("touchstart", (ev) => {
const e = ev.touches.item(0);
if (!e) return;
onMouseDown(e);
});
container.addEventListener("mouseup", () => {
isMouseDown = false;
});
container.addEventListener("touchend", () => {
isMouseDown = false;
});
}
onMouseMove;
appendChild(node) {
this.div.appendChild(node);
}
renderTransform(mode) {
const { x, y } = this.view;
if (mode != "skip_storage") {
this.diagram.inputController.setViewPosition({
x: x.value,
y: y.value
});
}
this.div.style.transform = `translate(${-x}px,${-y}px)`;
const diagramRect = this.diagram.getDiagramRect();
this.diagram.tableMap.forEach(
(tableController) => tableController.renderLinesTransform(diagramRect)
);
}
resetView() {
this.view.x.value = 0;
this.view.y.value = 0;
this.renderTransform();
}
exportJSON(json) {
json["view:x"] = this.view.x;
json["view:y"] = this.view.y;
}
};
TableController = class {
constructor(diagram, div, data) {
this.diagram = diagram;
this.div = div;
this.data = data;
const newTablePosition = diagram.getNewTablePosition();
this.translateX = new StoredNumber(this.data.name + "-x", 0);
this.translateY = new StoredNumber(this.data.name + "-y", 0);
if (this.translateX.value == 0 && this.translateY.value == 0) {
this.translateX.value = newTablePosition.x;
this.translateY.value = newTablePosition.y;
}
this.color = new StoredString(this.data.name + "-color", "");
this.div.addEventListener("mouseenter", () => {
const diagramRect = this.diagram.getDiagramRect();
this.renderLinesTransform(diagramRect);
});
this.div.addEventListener("mouseleave", () => {
const diagramRect = this.diagram.getDiagramRect();
this.renderLinesTransform(diagramRect);
});
this.div.innerHTML = /* html */
`
<div class='table-header'>
<div class='table-name'>${this.data.name}</div>
<div class='table-color-container'><input type='color'></div>
</div>
<table>
<tbody></tbody>
</table>
<div class='table-footer'>
<button class='add-field-button' title='add column'>+</button>
</div>
</div>
`;
this.tableHeader = this.div.querySelector(".table-header");
this.tableHeader.addEventListener("contextmenu", (event) => {
event.preventDefault();
this.diagram.inputController.selectTable(this.data.name);
});
this.colorInput = this.tableHeader.querySelector(
"input[type=color]"
);
this.colorInput.addEventListener("input", () => {
const color = this.colorInput.value;
this.color.value = color;
this.tableHeader.style.backgroundColor = color;
this.tableHeader.style.color = calcTextColor(color);
this.diagram.inputController.setTablePosition(this.data.name, {
x: this.translateX.value,
y: this.translateY.value,
color
});
});
this.tbody = this.div.querySelector("tbody");
this.addFieldButton = this.div.querySelector(
".add-field-button"
);
this.addFieldButton.addEventListener("click", () => {
const lastField = this.data.field_list[this.data.field_list.length - 1]?.name;
if (!lastField) {
this.showAddFieldMessage("Error: last field not detected");
} else {
this.diagram.inputController.addField(this.data.name, lastField);
this.showAddFieldMessage("(please input new column)");
}
});
}
translateX;
translateY;
color;
// self_field + table + other_field -> line
_lineMap = /* @__PURE__ */ new Map();
reverseLineSet = /* @__PURE__ */ new Set();
onMoveListenerSet = /* @__PURE__ */ new Set();
tableHeader;
colorInput;
tbody;
fieldMap = /* @__PURE__ */ new Map();
fieldCheckboxes = /* @__PURE__ */ new Map();
addFieldButton;
toFieldKey(own_field, table, other_field) {
return `${own_field}:${table}.${other_field}`;
}
getLine(own_field, table, other_field) {
const key = this.toFieldKey(own_field, table, other_field);
return this._lineMap.get(key);
}
setLine(own_field, table, other_field, line) {
const key = this.toFieldKey(own_field, table, other_field);
this._lineMap.set(key, line);
}
showAddFieldMessage(message) {
this.addFieldButton.textContent = message;
this.addFieldButton.classList.add("message-mode");
setTimeout(() => {
this.addFieldButton.textContent = "+";
this.addFieldButton.classList.remove("message-mode");
}, 3500);
}
getFieldElement(field) {
return this.fieldMap.get(field);
}
renameField(oldName, newName) {
const tr = this.fieldMap.get(oldName);
if (!tr) throw new Error("field not found, name: " + oldName);
this.fieldMap.delete(oldName);
this.fieldMap.set(newName, tr);
}
render(data) {
this.data = data;
const newFieldSet = /* @__PURE__ */ new Set();
data.field_list.forEach((field) => newFieldSet.add(field.name));
this.fieldMap.forEach((field, name) => {
if (!newFieldSet.has(name)) {
field.remove();
this.fieldMap.delete(name);
}
});
data.field_list.forEach(
({ name, type, is_null, is_primary_key, references, is_unique }) => {
const tags = [];
const icons = [];
if (is_primary_key) {
tags.push('<span title="primary key">PK</span>');
icons.push(
`<img title="primary key" class="icon" src="/icons/key-outline.svg">`
);
}
if (references) {
tags.push('<span title="foreign key">FK</span>');
icons.push(
`<img title="foreign key" class="icon" src="/icons/attach-outline.svg">`
);
}
if (is_unique) {
tags.push('<span title="unique">UQ</span>');
icons.push(
`<img title="unique" class="icon" src="/icons/snow-outline.svg">`
);
}
const mode = navigator.userAgent.includes("Mac OS") || navigator.userAgent.includes("Macintosh") || navigator.userAgent.includes("iPhone") || navigator.userAgent.includes("iPad") ? "icon" : "text";
const tag = mode == "icon" ? icons.join("") : tags.join(", ");
const null_text = is_null ? "NULL" : "";
let tr = this.fieldMap.get(name);
if (!tr) {
tr = document.createElement("tr");
tr.dataset.tableField = name;
tr.addEventListener("contextmenu", (event) => {
event.preventDefault();
this.diagram.inputController.selectField(data.name, name);
});
this.fieldMap.set(name, tr);
}
tr.hidden = !this.diagram.isDetailMode.value && tags.length === 0;
tr.innerHTML = /* html */
`
<td class='table-field-tag'>${tag}</td>
<td class='table-field-name'>
<label>
<input type='checkbox'>
${name}
</label>
</td>
<td class='table-field-type'>${type}</td>
<td class='table-field-null'>${null_text}</td>
`;
if (type.match(/^enum\(/i)) {
const td = tr.querySelector(".table-field-type");
td.title = type;
td.textContent = "enum";