UNPKG

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
"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";