diff --git a/packages/erd-editor/src/components/erd/Erd.ts b/packages/erd-editor/src/components/erd/Erd.ts index b11cc360..2e6c3139 100644 --- a/packages/erd-editor/src/components/erd/Erd.ts +++ b/packages/erd-editor/src/components/erd/Erd.ts @@ -20,6 +20,7 @@ import { changeColorAllAction$, unselectAllAction$, } from '@/engine/modules/editor/generator.actions'; +import { Viewport } from '@/engine/modules/editor/state'; import { streamScrollToAction, streamZoomLevelAction, @@ -51,6 +52,7 @@ const Erd: FC = (props, ctx) => { colorPickerShow: false, colorPickerX: 0, colorPickerY: 0, + colorPickerViewport: null as Viewport | null, colorPickerInitialColor: '', }); useErdShortcut(ctx); @@ -157,15 +159,18 @@ const Erd: FC = (props, ctx) => { }; onMounted(() => { - const { emitter } = app.value; + const { store, emitter } = app.value; const $root = root.value; addUnsubscribe( emitter.on({ openColorPicker: ({ payload: { x, y, color } }) => { + const { editor } = store.state; const rect = $root.getBoundingClientRect(); + state.colorPickerX = x - rect.x; state.colorPickerY = y - rect.y; + state.colorPickerViewport = editor.viewport; state.colorPickerInitialColor = color; state.colorPickerShow = true; }, @@ -224,6 +229,7 @@ const Erd: FC = (props, ctx) => { color=${state.colorPickerInitialColor} x=${state.colorPickerX} y=${state.colorPickerY} + viewport=${state.colorPickerViewport} .onChange=${handleChangeColorPicker} /> ` diff --git a/packages/erd-editor/src/components/primitives/color-picker/ColorPicker.ts b/packages/erd-editor/src/components/primitives/color-picker/ColorPicker.ts index cbfe0676..ee4c7ead 100644 --- a/packages/erd-editor/src/components/primitives/color-picker/ColorPicker.ts +++ b/packages/erd-editor/src/components/primitives/color-picker/ColorPicker.ts @@ -1,7 +1,15 @@ -import { createRef, FC, html, onMounted, ref } from '@dineug/r-html'; +import { + createRef, + FC, + html, + observable, + onMounted, + ref, +} from '@dineug/r-html'; // @ts-ignore import ColorPickerUI from '@easylogic/colorpicker'; +import { Viewport } from '@/engine/modules/editor/state'; import { useUnmounted } from '@/hooks/useUnmounted'; import * as styles from './ColorPicker.styles'; @@ -10,6 +18,7 @@ export type ColorPickerProps = { x: number; y: number; color: string; + viewport?: Viewport; onChange?: (color: string) => void; onLastUpdate?: (color: string) => void; }; @@ -17,6 +26,10 @@ export type ColorPickerProps = { const ColorPicker: FC = (props, ctx) => { const container = createRef(); const { addUnsubscribe } = useUnmounted(); + const state = observable({ + x: props.x, + y: props.y, + }); onMounted(() => { const $container = container.value; @@ -33,23 +46,41 @@ const ColorPicker: FC = (props, ctx) => { }, }); + if (props.viewport) { + const rect = $container.getBoundingClientRect(); + const width = props.x + rect.width; + const height = props.y + rect.height; + + if (props.viewport.width < width) { + const x = props.viewport.width - rect.width; + if (0 <= x) { + state.x = x; + } + } + if (props.viewport.height < height) { + const y = props.viewport.height - rect.height; + if (0 <= y) { + state.y = y; + } + } + } + addUnsubscribe(() => { colorPicker.destroy(); $container.removeChild(colorPicker.$root.el); }); }); - return () => - html` -
- `; + return () => html` +
+ `; }; export default ColorPicker; diff --git a/packages/erd-editor/src/components/visualization/Visualization.styles.ts b/packages/erd-editor/src/components/visualization/Visualization.styles.ts index 9a378748..a6425439 100644 --- a/packages/erd-editor/src/components/visualization/Visualization.styles.ts +++ b/packages/erd-editor/src/components/visualization/Visualization.styles.ts @@ -1 +1,8 @@ import { css } from '@dineug/r-html'; + +export const root = css` + position: relative; + height: 100%; + overflow: auto; + background-color: var(--canvas-background); +`; diff --git a/packages/erd-editor/src/components/visualization/Visualization.ts b/packages/erd-editor/src/components/visualization/Visualization.ts index 141c8aeb..ef12819d 100644 --- a/packages/erd-editor/src/components/visualization/Visualization.ts +++ b/packages/erd-editor/src/components/visualization/Visualization.ts @@ -1,22 +1,125 @@ -import { FC, html } from '@dineug/r-html'; -import { - create, - drag, - forceLink, - forceManyBody, - forceSimulation, - forceX, - forceY, - scaleOrdinal, - schemeCategory10, -} from 'd3'; +import { FC, html, observable, onBeforeMount, watch } from '@dineug/r-html'; +import { useAppContext } from '@/components/appContext'; +import Table from '@/components/visualization/table/Table'; +import { useUnmounted } from '@/hooks/useUnmounted'; +import { Table as TableType } from '@/internal-types'; +import { query } from '@/utils/collection/query'; + +import { createVisualization } from './createVisualization'; import * as styles from './Visualization.styles'; +const HEIGHT = 1200; +const MARGIN = 20; + export type VisualizationProps = {}; +type VisualizationState = { + preview: boolean; + drag: boolean; + table?: TableType | null; + columnId: string | null; + x: number; + y: number; +}; + const Visualization: FC = (props, ctx) => { - return () => html``; + const app = useAppContext(ctx); + const { addUnsubscribe } = useUnmounted(); + const state = observable({ + preview: false, + drag: false, + table: null, + columnId: null, + x: 0, + y: 0, + }); + + let d3SVG: ReturnType | null = null; + + const setViewBox = () => { + const { store } = app.value; + const { + editor: { viewport }, + } = store.state; + + d3SVG?.attr('viewBox', [ + -viewport.width / 2, + -HEIGHT / 2, + viewport.width, + HEIGHT, + ]); + }; + + onBeforeMount(() => { + const { store } = app.value; + const { editor } = store.state; + + d3SVG = createVisualization(store.state, { + onDragStart: () => { + state.drag = true; + }, + onDragEnd: () => { + state.drag = false; + }, + onStartPreview: ( + event: MouseEvent, + tableId: string | null, + columnId: string | null + ) => { + if (!tableId) return; + + const { store } = app.value; + const { collections } = store.state; + const table = query(collections) + .collection('tableEntities') + .selectById(tableId); + if (!table) return; + + state.columnId = columnId; + state.table = table; + state.x = event.clientX; + state.y = event.clientY; + state.preview = true; + }, + onEndPreview: () => { + state.preview = false; + }, + }); + + setViewBox(); + + addUnsubscribe( + watch(editor.viewport).subscribe(propName => { + if (propName !== 'width') return; + setViewBox(); + }), + () => { + d3SVG?.remove(); + d3SVG = null; + } + ); + }); + + return () => { + const showPreview = state.table && !state.drag && state.preview; + + return html` +
+ ${d3SVG?.node()} + ${showPreview + ? html` + <${Table} + table=${state.table} + columnId=${state.columnId} + x=${state.x + MARGIN} + y=${state.y} + /> + ` + : null} +
+ `; + }; }; export default Visualization; diff --git a/packages/erd-editor/src/components/visualization/createVisualization.ts b/packages/erd-editor/src/components/visualization/createVisualization.ts new file mode 100644 index 00000000..a503e6bf --- /dev/null +++ b/packages/erd-editor/src/components/visualization/createVisualization.ts @@ -0,0 +1,206 @@ +import { + create, + drag, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + scaleOrdinal, + schemeCategory10, +} from 'd3'; + +import { RootState } from '@/engine/state'; +import { ValuesType } from '@/internal-types'; +import { query } from '@/utils/collection/query'; + +const Group = { + table: 'table', + column: 'column', +} as const; +type Group = ValuesType; + +interface Node { + id: string; + group: Group; + name: string; + tableId?: string; +} + +interface Link { + source: string; + target: string; +} + +interface Visualization { + nodes: Node[]; + links: Link[]; +} + +function convertVisualization({ + doc: { tableIds, relationshipIds }, + collections, +}: RootState): Visualization { + const tables = query(collections) + .collection('tableEntities') + .selectByIds(tableIds); + const relationships = query(collections) + .collection('relationshipEntities') + .selectByIds(relationshipIds); + + const data: Visualization = { + nodes: [], + links: [], + }; + + tables.forEach(table => { + data.nodes.push({ + id: table.id, + name: table.name, + group: Group.table, + }); + query(collections) + .collection('tableColumnEntities') + .selectByIds(table.columnIds) + .forEach(column => { + data.nodes.push({ + id: column.id, + name: column.name, + group: Group.column, + tableId: table.id, + }); + data.links.push({ + source: table.id, + target: column.id, + }); + }); + }); + + relationships.forEach(relationship => { + const { start, end } = relationship; + if ( + start.tableId !== end.tableId && + isLink(data.links, start.tableId, end.tableId) + ) { + data.links.push({ + source: start.tableId, + target: end.tableId, + }); + } + }); + + return data; +} + +function isLink( + links: Link[], + startTableId: string, + endTableId: string +): boolean { + let result = true; + for (const link of links) { + if (link.source === startTableId && link.target === endTableId) { + result = false; + break; + } + } + return result; +} + +const scale = scaleOrdinal(schemeCategory10); + +type Props = { + onDragStart: () => void; + onDragEnd: () => void; + onStartPreview: ( + event: MouseEvent, + tableId: string | null, + columnId: string | null + ) => void; + onEndPreview: () => void; +}; + +function onDrag(simulation: any, props: Props): any { + return drag() + .on('start', (event, d: any) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + props.onDragStart(); + }) + .on('drag', (event, d: any) => { + d.fx = event.x; + d.fy = event.y; + }) + .on('end', (event, d: any) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + props.onDragEnd(); + }); +} + +export function createVisualization(state: RootState, props: Props) { + const data = convertVisualization(state); + const links = data.links.map(d => Object.create(d)); + const nodes = data.nodes.map(d => Object.create(d)); + + const simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(links).id((d: any) => d.id) + ) + .force('charge', forceManyBody()) + .force('x', forceX()) + .force('y', forceY()); + + const svg = create('svg'); + + const link = svg + .append('g') + .attr('stroke', '#999') + .attr('stroke-opacity', 0.6) + .selectAll('line') + .data(links) + .join('line') + .attr('stroke-width', Math.sqrt(2)); + + const node = svg + .append('g') + .attr('stroke', '#fff') + .attr('stroke-width', 1.5) + .selectAll('circle') + .data(nodes) + .join('circle') + .attr('r', 5) + .attr('fill', d => scale(d.group)) + .call(onDrag(simulation, props)); + + node.on('mouseenter', (event, d) => { + const node = data.nodes[d.index]; + let tableId: string | null = null; + let columnId: string | null = null; + if (node.group === Group.table) { + tableId = node.id; + } else if (node.group === Group.column && node.tableId) { + tableId = node.tableId; + columnId = node.id; + } + props.onStartPreview(event, tableId, columnId); + }); + node.on('mouseleave', () => { + props.onEndPreview(); + }); + + simulation.on('tick', () => { + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + node.attr('cx', d => d.x).attr('cy', d => d.y); + }); + + return svg; +} diff --git a/packages/erd-editor/src/components/visualization/table/Table.styles.ts b/packages/erd-editor/src/components/visualization/table/Table.styles.ts new file mode 100644 index 00000000..9a378748 --- /dev/null +++ b/packages/erd-editor/src/components/visualization/table/Table.styles.ts @@ -0,0 +1 @@ +import { css } from '@dineug/r-html'; diff --git a/packages/erd-editor/src/components/visualization/table/Table.ts b/packages/erd-editor/src/components/visualization/table/Table.ts new file mode 100644 index 00000000..04a8ea87 --- /dev/null +++ b/packages/erd-editor/src/components/visualization/table/Table.ts @@ -0,0 +1,97 @@ +import { FC, html, repeat } from '@dineug/r-html'; + +import { useAppContext } from '@/components/appContext'; +import * as styles from '@/components/erd/canvas/table/Table.styles'; +import EditInput from '@/components/primitives/edit-input/EditInput'; +import Column from '@/components/visualization/table/column/Column'; +import { Show } from '@/constants/schema'; +import { Table } from '@/internal-types'; +import { bHas } from '@/utils/bit'; +import { calcTableHeight, calcTableWidths } from '@/utils/calcTable'; +import { query } from '@/utils/collection/query'; + +export type TableProps = { + table: Table; + columnId: string | null; + x: number; + y: number; +}; + +const Table: FC = (props, ctx) => { + const app = useAppContext(ctx); + + return () => { + const { store } = app.value; + const { settings, collections } = store.state; + const { table, columnId, x, y } = props; + const tableWidths = calcTableWidths(table, store.state); + const height = calcTableHeight(table); + + const columns = query(collections) + .collection('tableColumnEntities') + .selectByIds(table.columnIds); + + return html` +
+
+
+
+
+
+ <${EditInput} + placeholder="table" + width=${table.ui.widthName} + value=${table.name} + /> +
+ ${bHas(settings.show, Show.tableComment) + ? html` +
+ <${EditInput} + placeholder="comment" + width=${table.ui.widthComment} + value=${table.comment} + /> +
+ ` + : null} +
+
+
+ ${repeat( + columns, + column => column.id, + column => + html` + <${Column} + column=${column} + selected=${column.id === columnId} + widthName=${tableWidths.name} + widthDataType=${tableWidths.dataType} + widthDefault=${tableWidths.default} + widthComment=${tableWidths.comment} + /> + ` + )} +
+
+ `; + }; +}; + +export default Table; diff --git a/packages/erd-editor/src/components/visualization/table/column/Column.styles.ts b/packages/erd-editor/src/components/visualization/table/column/Column.styles.ts new file mode 100644 index 00000000..9a378748 --- /dev/null +++ b/packages/erd-editor/src/components/visualization/table/column/Column.styles.ts @@ -0,0 +1 @@ +import { css } from '@dineug/r-html'; diff --git a/packages/erd-editor/src/components/visualization/table/column/Column.ts b/packages/erd-editor/src/components/visualization/table/column/Column.ts new file mode 100644 index 00000000..0cba0b32 --- /dev/null +++ b/packages/erd-editor/src/components/visualization/table/column/Column.ts @@ -0,0 +1,170 @@ +import { DOMTemplateLiterals, FC, html, repeat } from '@dineug/r-html'; + +import { useAppContext } from '@/components/appContext'; +import * as styles from '@/components/erd/canvas/table/column/Column.styles'; +import ColumnDataType from '@/components/erd/canvas/table/column/column-data-type/ColumnDataType'; +import ColumnKey from '@/components/erd/canvas/table/column/column-key/ColumnKey'; +import ColumnNotNull from '@/components/erd/canvas/table/column/column-not-null/ColumnNotNull'; +import ColumnOption from '@/components/erd/canvas/table/column/column-option/ColumnOption'; +import EditInput from '@/components/primitives/edit-input/EditInput'; +import { + COLUMN_AUTO_INCREMENT_WIDTH, + COLUMN_UNIQUE_WIDTH, +} from '@/constants/layout'; +import { + ColumnOption as ColumnOptionType, + ColumnType, + Show, +} from '@/constants/schema'; +import { Column } from '@/internal-types'; +import { bHas } from '@/utils/bit'; + +export type ColumnProps = { + column: Column; + selected: boolean; + widthName: number; + widthDataType: number; + widthDefault: number; + widthComment: number; +}; + +type ColumnOrderTpl = { + columnType: number; + template: DOMTemplateLiterals | null; +}; + +const Column: FC = (props, ctx) => { + const app = useAppContext(ctx); + + const getColumnOrder = (): ColumnOrderTpl[] => { + const { store } = app.value; + const { settings } = store.state; + const { column, widthName, widthDataType, widthDefault, widthComment } = + props; + + return settings.columnOrder + .map((columnType: number) => { + let template: DOMTemplateLiterals | null = null; + + switch (columnType) { + case ColumnType.columnName: + template = html` +
+ <${EditInput} + placeholder="column" + width=${widthName} + value=${column.name} + /> +
+ `; + break; + case ColumnType.columnDefault: + template = bHas(settings.show, Show.columnDefault) + ? html` +
+ <${EditInput} + placeholder="default" + width=${widthDefault} + value=${column.default} + /> +
+ ` + : null; + break; + case ColumnType.columnComment: + template = bHas(settings.show, Show.columnComment) + ? html` +
+ <${EditInput} + placeholder="comment" + width=${widthComment} + value=${column.comment} + /> +
+ ` + : null; + break; + case ColumnType.columnDataType: + template = bHas(settings.show, Show.columnDataType) + ? html` +
+ <${ColumnDataType} + tableId=${column.tableId} + columnId=${column.id} + width=${widthDataType} + value=${column.dataType} + /> +
+ ` + : null; + break; + case ColumnType.columnNotNull: + template = bHas(settings.show, Show.columnNotNull) + ? html` +
+ <${ColumnNotNull} options=${column.options} /> +
+ ` + : null; + break; + case ColumnType.columnUnique: + template = bHas(settings.show, Show.columnUnique) + ? html` +
+ <${ColumnOption} + checked=${bHas(column.options, ColumnOptionType.unique)} + width=${COLUMN_UNIQUE_WIDTH} + text="UQ" + title="Unique" + /> +
+ ` + : null; + break; + case ColumnType.columnAutoIncrement: + template = bHas(settings.show, Show.columnAutoIncrement) + ? html` +
+ <${ColumnOption} + checked=${bHas( + column.options, + ColumnOptionType.autoIncrement + )} + width=${COLUMN_AUTO_INCREMENT_WIDTH} + text="AI" + title="Auto Increment" + /> +
+ ` + : null; + break; + } + + return { + columnType, + template, + }; + }) + .filter(({ template }) => Boolean(template)); + }; + + return () => { + const { column, selected } = props; + + return html` +
+ <${ColumnKey} keys=${column.ui.keys} /> + ${repeat( + getColumnOrder(), + ({ columnType }) => columnType, + ({ template }) => template + )} +
+ `; + }; +}; + +export default Column;