Implemented:
- node rename working in modal - add child node on click - removing node and reasigning the edges - creating subgraphs - breadcrumbs for navigtion
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"antd": "^5.28.0",
|
"antd": "^5.28.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"viz.js": "^2.1.2"
|
"viz.js": "^2.1.2"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
@@ -1984,6 +1986,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||||
|
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.0",
|
"version": "24.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||||
@@ -3754,6 +3763,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"antd": "^5.28.0",
|
"antd": "^5.28.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"viz.js": "^2.1.2"
|
"viz.js": "^2.1.2"
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
|||||||
48
src/App.tsx
48
src/App.tsx
@@ -1,18 +1,38 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import React, { useState } from 'react';
|
||||||
import { GraphRenderer } from "./Graph";
|
import { Layout, theme, Breadcrumb } from 'antd';
|
||||||
|
import Graph, { NodeModel } from './components/Graph';
|
||||||
|
import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
|
||||||
|
|
||||||
export default function App() {
|
const { Content } = Layout;
|
||||||
const containerRef = useRef(null);
|
|
||||||
const firstLevelGraph = new GraphRenderer(containerRef);
|
const App: React.FC = () => {
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer, borderRadiusLG },
|
||||||
|
} = theme.useToken();
|
||||||
|
const [graphLevel, setGraphLevel] = useState<BreadcrumbItemType[]>([])
|
||||||
|
|
||||||
|
function setGraphLevelPath(path: BreadcrumbItemType[]) {
|
||||||
|
setGraphLevel(path)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
firstLevelGraph.render();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-4">
|
<Layout>
|
||||||
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{minHeight: '600px', overflow: 'auto'}}></div>
|
<Layout>
|
||||||
</div>
|
<Breadcrumb style={{ margin: '8px 0 0 16px' }} items={graphLevel} />
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
margin: '8px 16px',
|
||||||
|
padding: 24,
|
||||||
|
minHeight: 280,
|
||||||
|
background: colorBgContainer,
|
||||||
|
borderRadius: borderRadiusLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Graph setGraphPath={setGraphLevelPath} />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import Viz from 'viz.js';
|
|
||||||
import { Module, render } from 'viz.js/full.render.js';
|
|
||||||
import { graphToDot } from "./Graphviz";
|
|
||||||
import * as d3 from 'd3';
|
|
||||||
import { NodeContextMenu } from "./NodeContextMenu";
|
|
||||||
|
|
||||||
const viz = new Viz({ Module, render });
|
|
||||||
|
|
||||||
export class GraphRenderer {
|
|
||||||
graph: Graph;
|
|
||||||
setGraph: React.Dispatch<React.SetStateAction<Graph>>;
|
|
||||||
containerRef: React.RefObject<null>;
|
|
||||||
contextMenu: NodeContextMenu;
|
|
||||||
|
|
||||||
constructor(containerRef: React.RefObject<null>) {
|
|
||||||
[this.graph, this.setGraph] = useState(defaultGraph());
|
|
||||||
this.containerRef = containerRef;
|
|
||||||
this.contextMenu = new NodeContextMenu(containerRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async render() {
|
|
||||||
const dot = graphToDot(this.graph);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
|
|
||||||
const container = this.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(svgElement);
|
|
||||||
this.attachInteractions(svgElement);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Viz render error', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attachInteractions(svgElement: SVGSVGElement) {
|
|
||||||
const svg = d3.select(svgElement);
|
|
||||||
const self = this;
|
|
||||||
svg.selectAll('g.node')
|
|
||||||
.style('cursor', 'pointer')
|
|
||||||
.on('click', function (event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
const id = d3.select(this).attr('id');
|
|
||||||
self.createChildNode(id);
|
|
||||||
self.render();
|
|
||||||
})
|
|
||||||
.on('contextmenu', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const id = d3.select(this).attr('id');
|
|
||||||
const { clientX: x, clientY: y } = event;
|
|
||||||
self.contextMenu.setContextMenu({ x, y, nodeId: id });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createChildNode(parentId: string) {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
this.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultGraph(): Graph {
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{ id: 'A', label: 'A' },
|
|
||||||
{ id: 'B', label: 'B' },
|
|
||||||
{ id: 'C', label: 'C' }
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ from: 'A', to: 'B' },
|
|
||||||
{ from: 'B', to: 'C' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Graph {
|
|
||||||
nodes: Node[] = [];
|
|
||||||
edges: Edge[] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Edge {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
|
|
||||||
constructor(from: string, to: string) {
|
|
||||||
this.from = from;
|
|
||||||
this.to = to;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Node {
|
|
||||||
public id: string;
|
|
||||||
public label?: string;
|
|
||||||
|
|
||||||
constructor(id: string) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
265
src/components/Graph.tsx
Normal file
265
src/components/Graph.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState, type MouseEventHandler } from "react";
|
||||||
|
import Viz from 'viz.js';
|
||||||
|
import { Module, render } from 'viz.js/full.render.js';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { graphToDot } from "../Graphviz";
|
||||||
|
import { Dropdown, type MenuProps } from "antd";
|
||||||
|
import { Modal } from "antd";
|
||||||
|
import { Input } from "antd";
|
||||||
|
import type { BreadcrumbItemType } from "antd/es/breadcrumb/Breadcrumb";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
|
||||||
|
export class GraphModel {
|
||||||
|
nodes: NodeModel[] = [];
|
||||||
|
edges: EdgeModel[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EdgeModel {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
|
||||||
|
constructor(from: string, to: string) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeModel {
|
||||||
|
public id: string;
|
||||||
|
public label?: string;
|
||||||
|
|
||||||
|
constructor(id: string, name: string | undefined = undefined) {
|
||||||
|
this.id = id;
|
||||||
|
this.label = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLevel extends BreadcrumbItemType {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const viz = new Viz({ Module, render });
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
label: 'Rename',
|
||||||
|
extra: 'ctrl + n',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'subgraph',
|
||||||
|
label: 'Subgraph',
|
||||||
|
extra: 'ctrl + s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remove',
|
||||||
|
label: 'Remove',
|
||||||
|
extra: 'ctrl + r'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const graphsById = new Map<string, GraphModel>();
|
||||||
|
|
||||||
|
export default function Graph({ setGraphPath }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [graph, setGraph] = useState(defaultGraph());
|
||||||
|
const [contextMenuOpened, openContextMenu] = useState(false);
|
||||||
|
const [renameModalOpened, openRenameModal] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||||
|
const [nodeId, selectNodeId] = useState('');
|
||||||
|
const [nodeName, setSelectedNodeName] = useState('');
|
||||||
|
const [graphId, selectGraphId] = useState('main');
|
||||||
|
const [graphsPath, setGraphsPath] = useState([createPathSegment('main', 'Main')])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.info(graphsPath);
|
||||||
|
setGraphPath(graphsPath);
|
||||||
|
}, [graphsPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
renderGraph();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [graph]);
|
||||||
|
|
||||||
|
async function renderGraph() {
|
||||||
|
const dot = graphToDot(graph);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(svgElement);
|
||||||
|
attachInteractions(svgElement);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Viz render error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachInteractions(svgElement: SVGSVGElement) {
|
||||||
|
const svg = d3.select(svgElement);
|
||||||
|
svg.selectAll('g.node')
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.on('click', function (event) {
|
||||||
|
const id = d3.select(this).attr('id');
|
||||||
|
createChildNode(id);
|
||||||
|
renderGraph();
|
||||||
|
})
|
||||||
|
.on('contextmenu', function (event) {
|
||||||
|
const id = d3.select(this).attr('id');
|
||||||
|
const name = d3.select(this).attr('label');
|
||||||
|
handleOpenContextMenuClick(event, id, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextMenuOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
openContextMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenContextMenuClick(event: React.MouseEvent<HTMLDivElement, MouseEvent>, id: string, name: string) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectNodeId(id);
|
||||||
|
setSelectedNodeName(name)
|
||||||
|
setCoords({ x: event.clientX, y: event.clientY });
|
||||||
|
openContextMenu(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChildNode(parentId: string) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: 'New node' }], edges: [...prev.edges, { from: parentId, to: id }] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(id: string) {
|
||||||
|
setGraph(prev => ({ ...prev, nodes: [...prev.nodes.filter(n => n.id !== nodeId)], edges: removeEdgesOfNode(id, prev.edges) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEdgesOfNode(nodeId: string, edges: EdgeModel[]): EdgeModel[] {
|
||||||
|
const parentConnection = edges.find(e => e.to === nodeId);
|
||||||
|
|
||||||
|
if (parentConnection) {
|
||||||
|
edges.filter(e => e.from === nodeId).forEach(e => {
|
||||||
|
e.from = parentConnection.from;
|
||||||
|
});
|
||||||
|
edges = edges.filter(e => e.to !== nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentConnection) {
|
||||||
|
edges = edges.filter(e => e.from !== nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...edges];
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameNode() {
|
||||||
|
const node = graph.nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.label = nodeName;
|
||||||
|
setGraph(prev => ({ ...prev, nodes: graph.nodes }));
|
||||||
|
openRenameModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'rename': {
|
||||||
|
openRenameModal(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'remove': {
|
||||||
|
removeNode(nodeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'subgraph': {
|
||||||
|
const selectedNode = graph.nodes.find(n => n.id === nodeId);
|
||||||
|
const selectedNodeName = selectedNode?.label;
|
||||||
|
graphsById.set(graphId, cloneDeep(graph));
|
||||||
|
selectGraphId(nodeId);
|
||||||
|
let selectedGraph = graphsById.get(nodeId);
|
||||||
|
|
||||||
|
if (!selectedGraph) {
|
||||||
|
selectedGraph = defaultGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
setGraph(selectedGraph);
|
||||||
|
renderGraph();
|
||||||
|
graphsPath.push(createPathSegment(nodeId, selectedNodeName));
|
||||||
|
setGraphsPath([...graphsPath])
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPathSegment(pathSegmentId: string, selectedNodeName: string | undefined): BreadcrumbItemType {
|
||||||
|
return {
|
||||||
|
title: selectedNodeName,
|
||||||
|
key: pathSegmentId,
|
||||||
|
onClick: (e) => {
|
||||||
|
console.info(e);
|
||||||
|
const index = graphsPath.findIndex(p => p.key === pathSegmentId);
|
||||||
|
setGraphsPath(prev => {
|
||||||
|
prev.splice(index + 1);
|
||||||
|
|
||||||
|
return [...prev];
|
||||||
|
});
|
||||||
|
const graph = graphsById.get(pathSegmentId);
|
||||||
|
if (graph) {
|
||||||
|
selectGraphId(pathSegmentId);
|
||||||
|
setGraph(graph);
|
||||||
|
renderGraph();
|
||||||
|
} else {
|
||||||
|
throw Error(`No graph with id '${pathSegmentId}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as BreadcrumbItemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div ref={containerRef} className="w-full h-full bg-white rounded shadow" style={{ minHeight: '600px', overflow: 'auto' }}>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Dropdown menu={{ items, onClick: onMenuClick }} trigger={['contextMenu']} open={contextMenuOpened} onOpenChange={contextMenuOpenChange} getPopupContainer={() => document.body}
|
||||||
|
// 👇 Key part: manually position the dropdown
|
||||||
|
overlayStyle={{
|
||||||
|
position: "absolute",
|
||||||
|
left: coords.x,
|
||||||
|
top: coords.y,
|
||||||
|
}}>
|
||||||
|
|
||||||
|
</Dropdown>
|
||||||
|
<Modal
|
||||||
|
title="Rename"
|
||||||
|
open={renameModalOpened}
|
||||||
|
onOk={() => renameNode()}
|
||||||
|
onCancel={() => openRenameModal(false)}
|
||||||
|
>
|
||||||
|
<Input value={nodeName} onChange={(e) => setSelectedNodeName(e.target.value)} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGraph(): GraphModel {
|
||||||
|
const start = crypto.randomUUID();
|
||||||
|
const end = crypto.randomUUID();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{ id: start, label: 'Start' },
|
||||||
|
{ id: end, label: 'End' }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: start, to: end },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user