Diagram versioning (#560)

* clean up sidesheet

* clean up sharing

* add revisions sidesheet

* update getCommits and clean up

* update date localization

* load diagram in read only mode from previous version

* disable input from control panel and popovers

* add restore warning modal

* separate share and versions

* update versions

* finalize versioning implementation, add pagination

* fix package-lock.json

* clear versions cache on flush storgae

* disable menubar items when in read only mode

* disable remaining fields in readonlt

* suppress eslint only-export-components rule

* show loading version progress
This commit is contained in:
1ilit
2025-08-24 22:06:58 +04:00
committed by GitHub
parent 1eb4e298e9
commit da7ccee51c
42 changed files with 3219 additions and 1713 deletions

View File

@@ -6,7 +6,7 @@ module.exports = {
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react/jsx-runtime", "plugin:react/jsx-runtime",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier" "prettier",
], ],
ignorePatterns: ["dist", ".eslintrc.cjs"], ignorePatterns: ["dist", ".eslintrc.cjs"],
parserOptions: { ecmaVersion: "latest", sourceType: "module" }, parserOptions: { ecmaVersion: "latest", sourceType: "module" },
@@ -18,5 +18,6 @@ module.exports = {
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
"react/prop-types": 0, "react/prop-types": 0,
"react-refresh/only-export-components": "off",
}, },
}; };

3965
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "client-vite", "name": "drawdb",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -32,6 +32,8 @@
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lexical": "^0.12.5", "lexical": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.7.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"node-sql-parser": "^5.3.11", "node-sql-parser": "^5.3.11",
"oracle-sql-parser": "^0.1.0", "oracle-sql-parser": "^0.1.0",

View File

@@ -1,11 +1,12 @@
import axios from "axios"; import axios from "axios";
const filename = "share.json"; export const SHARE_FILENAME = "share.json";
const description = "drawDB diagram"; export const VERSION_FILENAME = "versionned.json";
const description = "drawDB diagram";
const baseUrl = import.meta.env.VITE_BACKEND_URL; const baseUrl = import.meta.env.VITE_BACKEND_URL;
export async function create(content) { export async function create(filename, content) {
const res = await axios.post(`${baseUrl}/gists`, { const res = await axios.post(`${baseUrl}/gists`, {
public: false, public: false,
filename, filename,
@@ -16,11 +17,13 @@ export async function create(content) {
return res.data.data.id; return res.data.data.id;
} }
export async function patch(gistId, content) { export async function patch(gistId, filename, content) {
await axios.patch(`${baseUrl}/gists/${gistId}`, { const { deleted } = await axios.patch(`${baseUrl}/gists/${gistId}`, {
filename, filename,
content, content,
}); });
return deleted;
} }
export async function del(gistId) { export async function del(gistId) {
@@ -32,3 +35,39 @@ export async function get(gistId) {
return res.data; return res.data;
} }
export async function getCommits(gistId, perPage = 20, page = 1) {
const res = await axios.get(`${baseUrl}/gists/${gistId}/commits`, {
params: {
per_page: perPage,
page,
},
});
return res.data;
}
export async function getVersion(gistId, sha) {
const res = await axios.get(`${baseUrl}/gists/${gistId}/${sha}`);
return res.data;
}
export async function getCommitsWithFile(
gistId,
file,
limit = 10,
cursor = null,
) {
const res = await axios.get(
`${baseUrl}/gists/${gistId}/file-versions/${file}`,
{
params: {
limit,
cursor,
},
},
);
return res.data;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -179,6 +179,7 @@ export default function Area({
backgroundColor: "#2F68ADB3", backgroundColor: "#2F68ADB3",
}} }}
onClick={lockUnlockArea} onClick={lockUnlockArea}
disabled={layout.readOnly}
/> />
<Popover <Popover
visible={areaIsOpen() && !layout.sidebar} visible={areaIsOpen() && !layout.sidebar}
@@ -257,6 +258,7 @@ function EditPopoverContent({ data }) {
const { updateArea, deleteArea } = useAreas(); const { updateArea, deleteArea } = useAreas();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const initialColorRef = useRef(data.color); const initialColorRef = useRef(data.color);
const handleColorPick = (color) => { const handleColorPick = (color) => {
@@ -302,6 +304,7 @@ function EditPopoverContent({ data }) {
value={data.name} value={data.name}
placeholder={t("name")} placeholder={t("name")}
className="me-2" className="me-2"
readonly={layout.readOnly}
onChange={(value) => updateArea(data.id, { name: value })} onChange={(value) => updateArea(data.id, { name: value })}
onFocus={(e) => setEditField({ name: e.target.value })} onFocus={(e) => setEditField({ name: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -325,6 +328,7 @@ function EditPopoverContent({ data }) {
/> />
<ColorPicker <ColorPicker
usePopover={true} usePopover={true}
readOnly={layout.readOnly}
value={data.color} value={data.color}
onChange={(color) => updateArea(data.id, { color })} onChange={(color) => updateArea(data.id, { color })}
onColorPick={(color) => handleColorPick(color)} onColorPick={(color) => handleColorPick(color)}
@@ -336,6 +340,7 @@ function EditPopoverContent({ data }) {
type="danger" type="danger"
block block
onClick={() => deleteArea(data.id, true)} onClick={() => deleteArea(data.id, true)}
disabled={layout.readOnly}
> >
{t("delete")} {t("delete")}
</Button> </Button>

View File

@@ -279,6 +279,7 @@ export default function Canvas() {
if (!e.isPrimary) return; if (!e.isPrimary) return;
if (panning.isPanning) { if (panning.isPanning) {
setTransform((prev) => ({ setTransform((prev) => ({
...prev, ...prev,
@@ -294,6 +295,8 @@ export default function Canvas() {
return; return;
} }
if(layout.readOnly) return;
if (linking) { if (linking) {
setLinkingLine({ setLinkingLine({
...linkingLine, ...linkingLine,

View File

@@ -249,6 +249,7 @@ export default function Note({ data, onPointerDown }) {
backgroundColor: "#2F68ADB3", backgroundColor: "#2F68ADB3",
}} }}
onClick={lockUnlockNote} onClick={lockUnlockNote}
disabled={layout.readOnly}
/> />
<Popover <Popover
visible={ visible={
@@ -280,6 +281,7 @@ export default function Note({ data, onPointerDown }) {
value={data.title} value={data.title}
placeholder={t("title")} placeholder={t("title")}
className="me-2" className="me-2"
readonly={layout.readOnly}
onChange={(value) => onChange={(value) =>
updateNote(data.id, { title: value }) updateNote(data.id, { title: value })
} }
@@ -307,6 +309,7 @@ export default function Note({ data, onPointerDown }) {
/> />
<ColorPicker <ColorPicker
usePopover={true} usePopover={true}
readOnly={layout.readOnly}
value={data.color} value={data.color}
onChange={(color) => updateNote(data.id, { color })} onChange={(color) => updateNote(data.id, { color })}
onColorPick={(color) => handleColorPick(color)} onColorPick={(color) => handleColorPick(color)}
@@ -314,9 +317,10 @@ export default function Note({ data, onPointerDown }) {
</div> </div>
<div className="flex"> <div className="flex">
<Button <Button
icon={<IconDeleteStroked />}
type="danger"
block block
type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteNote(data.id, true)} onClick={() => deleteNote(data.id, true)}
> >
{t("delete")} {t("delete")}
@@ -343,6 +347,7 @@ export default function Note({ data, onPointerDown }) {
</div> </div>
<textarea <textarea
id={`note_${data.id}`} id={`note_${data.id}`}
readOnly={layout.readOnly}
value={data.content} value={data.content}
onChange={handleChange} onChange={handleChange}
onFocus={(e) => onFocus={(e) =>

View File

@@ -171,6 +171,7 @@ export default function Table({
style={{ style={{
backgroundColor: "#2f68adb3", backgroundColor: "#2f68adb3",
}} }}
disabled={layout.readOnly}
onClick={lockUnlockTable} onClick={lockUnlockTable}
/> />
<Button <Button
@@ -234,6 +235,7 @@ export default function Table({
block block
style={{ marginTop: "8px" }} style={{ marginTop: "8px" }}
onClick={() => deleteTable(tableData.id)} onClick={() => deleteTable(tableData.id)}
disabled={layout.readOnly}
> >
{t("delete")} {t("delete")}
</Button> </Button>

View File

@@ -126,7 +126,7 @@ export default function ControlPanel({
const { selectedElement, setSelectedElement } = useSelect(); const { selectedElement, setSelectedElement } = useSelect();
const { transform, setTransform } = useTransform(); const { transform, setTransform } = useTransform();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { setGistId } = useContext(IdContext); const { version, setGistId } = useContext(IdContext);
const navigate = useNavigate(); const navigate = useNavigate();
const invertLayout = (component) => const invertLayout = (component) =>
@@ -615,6 +615,9 @@ export default function ControlPanel({
} }
}; };
const del = () => { const del = () => {
if (layout.readonly) {
return;
}
switch (selectedElement.element) { switch (selectedElement.element) {
case ObjectType.TABLE: case ObjectType.TABLE:
deleteTable(selectedElement.id); deleteTable(selectedElement.id);
@@ -630,6 +633,9 @@ export default function ControlPanel({
} }
}; };
const duplicate = () => { const duplicate = () => {
if (layout.readonly) {
return;
}
switch (selectedElement.element) { switch (selectedElement.element) {
case ObjectType.TABLE: { case ObjectType.TABLE: {
const copiedTable = tables.find((t) => t.id === selectedElement.id); const copiedTable = tables.find((t) => t.id === selectedElement.id);
@@ -685,6 +691,9 @@ export default function ControlPanel({
} }
}; };
const paste = () => { const paste = () => {
if (layout.readonly) {
return;
}
navigator.clipboard.readText().then((text) => { navigator.clipboard.readText().then((text) => {
let obj = null; let obj = null;
try { try {
@@ -718,6 +727,9 @@ export default function ControlPanel({
}); });
}; };
const cut = () => { const cut = () => {
if (layout.readonly) {
return;
}
copy(); copy();
del(); del();
}; };
@@ -747,10 +759,12 @@ export default function ControlPanel({
save: { save: {
function: save, function: save,
shortcut: "Ctrl+S", shortcut: "Ctrl+S",
disabled: layout.readOnly,
}, },
save_as: { save_as: {
function: saveDiagramAs, function: saveDiagramAs,
shortcut: "Ctrl+Shift+S", shortcut: "Ctrl+Shift+S",
disabled: layout.readOnly,
}, },
save_as_template: { save_as_template: {
function: () => { function: () => {
@@ -775,6 +789,7 @@ export default function ControlPanel({
function: () => { function: () => {
setModal(MODAL.RENAME); setModal(MODAL.RENAME);
}, },
disabled: layout.readOnly,
}, },
delete_diagram: { delete_diagram: {
warning: { warning: {
@@ -805,6 +820,7 @@ export default function ControlPanel({
{ {
function: fileImport, function: fileImport,
name: "JSON", name: "JSON",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -812,6 +828,7 @@ export default function ControlPanel({
setImportFrom(IMPORT_FROM.DBML); setImportFrom(IMPORT_FROM.DBML);
}, },
name: "DBML", name: "DBML",
disabled: layout.readOnly,
}, },
], ],
}, },
@@ -824,6 +841,7 @@ export default function ControlPanel({
setImportDb(DB.MYSQL); setImportDb(DB.MYSQL);
}, },
name: "MySQL", name: "MySQL",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -831,6 +849,7 @@ export default function ControlPanel({
setImportDb(DB.POSTGRES); setImportDb(DB.POSTGRES);
}, },
name: "PostgreSQL", name: "PostgreSQL",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -838,6 +857,7 @@ export default function ControlPanel({
setImportDb(DB.SQLITE); setImportDb(DB.SQLITE);
}, },
name: "SQLite", name: "SQLite",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -845,6 +865,7 @@ export default function ControlPanel({
setImportDb(DB.MARIADB); setImportDb(DB.MARIADB);
}, },
name: "MariaDB", name: "MariaDB",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -852,6 +873,7 @@ export default function ControlPanel({
setImportDb(DB.MSSQL); setImportDb(DB.MSSQL);
}, },
name: "MSSQL", name: "MSSQL",
disabled: layout.readOnly,
}, },
{ {
function: () => { function: () => {
@@ -860,6 +882,7 @@ export default function ControlPanel({
}, },
name: "Oracle", name: "Oracle",
label: "Beta", label: "Beta",
disabled: layout.readOnly,
}, },
], ],
}), }),
@@ -868,6 +891,7 @@ export default function ControlPanel({
setModal(MODAL.IMPORT_SRC); setModal(MODAL.IMPORT_SRC);
}, },
disabled: layout.readOnly,
}, },
export_source: { export_source: {
...(database === DB.GENERIC && { ...(database === DB.GENERIC && {
@@ -1159,10 +1183,12 @@ export default function ControlPanel({
undo: { undo: {
function: undo, function: undo,
shortcut: "Ctrl+Z", shortcut: "Ctrl+Z",
disabled: layout.readOnly || undoStack.length === 0,
}, },
redo: { redo: {
function: redo, function: redo,
shortcut: "Ctrl+Y", shortcut: "Ctrl+Y",
disabled: layout.readOnly || redoStack.length === 0,
}, },
clear: { clear: {
warning: { warning: {
@@ -1194,14 +1220,17 @@ export default function ControlPanel({
); );
}); });
}, },
disabled: layout.readOnly,
}, },
edit: { edit: {
function: edit, function: edit,
shortcut: "Ctrl+E", shortcut: "Ctrl+E",
disabled: layout.readOnly,
}, },
cut: { cut: {
function: cut, function: cut,
shortcut: "Ctrl+X", shortcut: "Ctrl+X",
disabled: layout.readOnly,
}, },
copy: { copy: {
function: copy, function: copy,
@@ -1210,14 +1239,17 @@ export default function ControlPanel({
paste: { paste: {
function: paste, function: paste,
shortcut: "Ctrl+V", shortcut: "Ctrl+V",
disabled: layout.readOnly,
}, },
duplicate: { duplicate: {
function: duplicate, function: duplicate,
shortcut: "Ctrl+D", shortcut: "Ctrl+D",
disabled: layout.readOnly,
}, },
delete: { delete: {
function: del, function: del,
shortcut: "Del", shortcut: "Del",
disabled: layout.readOnly,
}, },
copy_as_image: { copy_as_image: {
function: copyAsImage, function: copyAsImage,
@@ -1404,6 +1436,7 @@ export default function ControlPanel({
}, },
table_width: { table_width: {
function: () => setModal(MODAL.TABLE_WIDTH), function: () => setModal(MODAL.TABLE_WIDTH),
disabled: layout.readOnly,
}, },
language: { language: {
function: () => setModal(MODAL.LANGUAGE), function: () => setModal(MODAL.LANGUAGE),
@@ -1417,6 +1450,7 @@ export default function ControlPanel({
message: t("are_you_sure_flush_storage"), message: t("are_you_sure_flush_storage"),
}, },
function: async () => { function: async () => {
localStorage.removeItem("versions_cache");
db.delete() db.delete()
.then(() => { .then(() => {
Toast.success(t("storage_flushed")); Toast.success(t("storage_flushed"));
@@ -1513,6 +1547,8 @@ export default function ControlPanel({
/> />
<Sidesheet <Sidesheet
type={sidesheet} type={sidesheet}
title={title}
setTitle={setTitle}
onClose={() => setSidesheet(SIDESHEET.NONE)} onClose={() => setSidesheet(SIDESHEET.NONE)}
/> />
</> </>
@@ -1603,47 +1639,46 @@ export default function ControlPanel({
<Divider layout="vertical" margin="8px" /> <Divider layout="vertical" margin="8px" />
<Tooltip content={t("undo")} position="bottom"> <Tooltip content={t("undo")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm flex items-center" className="py-1 px-2 hover-2 rounded-sm flex items-center disabled:opacity-50"
disabled={undoStack.length === 0 || layout.readOnly}
onClick={undo} onClick={undo}
> >
<IconUndo <IconUndo size="large" />
size="large"
style={{ color: undoStack.length === 0 ? "#9598a6" : "" }}
/>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content={t("redo")} position="bottom"> <Tooltip content={t("redo")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm flex items-center" className="py-1 px-2 hover-2 rounded-sm flex items-center disabled:opacity-50"
disabled={redoStack.length === 0 || layout.readOnly}
onClick={redo} onClick={redo}
> >
<IconRedo <IconRedo size="large" />
size="large"
style={{ color: redoStack.length === 0 ? "#9598a6" : "" }}
/>
</button> </button>
</Tooltip> </Tooltip>
<Divider layout="vertical" margin="8px" /> <Divider layout="vertical" margin="8px" />
<Tooltip content={t("add_table")} position="bottom"> <Tooltip content={t("add_table")} position="bottom">
<button <button
className="flex items-center py-1 px-2 hover-2 rounded-sm" className="flex items-center py-1 px-2 hover-2 rounded-sm disabled:opacity-50"
onClick={() => addTable()} onClick={() => addTable()}
disabled={layout.readOnly}
> >
<IconAddTable /> <IconAddTable />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content={t("add_area")} position="bottom"> <Tooltip content={t("add_area")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm flex items-center" className="py-1 px-2 hover-2 rounded-sm flex items-center disabled:opacity-50"
onClick={() => addArea()} onClick={() => addArea()}
disabled={layout.readOnly}
> >
<IconAddArea /> <IconAddArea />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content={t("add_note")} position="bottom"> <Tooltip content={t("add_note")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm flex items-center" className="py-1 px-2 hover-2 rounded-sm flex items-center disabled:opacity-50"
onClick={() => addNote()} onClick={() => addNote()}
disabled={layout.readOnly}
> >
<IconAddNote /> <IconAddNote />
</button> </button>
@@ -1651,12 +1686,21 @@ export default function ControlPanel({
<Divider layout="vertical" margin="8px" /> <Divider layout="vertical" margin="8px" />
<Tooltip content={t("save")} position="bottom"> <Tooltip content={t("save")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm flex items-center" className="py-1 px-2 hover-2 rounded-sm flex items-center disabled:opacity-50"
onClick={save} onClick={save}
disabled={layout.readOnly}
> >
<IconSaveStroked size="extra-large" /> <IconSaveStroked size="extra-large" />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content={t("versions")} position="bottom">
<button
className="py-1 px-2 hover-2 rounded-sm text-xl -mt-0.5"
onClick={() => setSidesheet(SIDESHEET.VERSIONS)}
>
<i className="fa-solid fa-code-branch" />{" "}
</button>
</Tooltip>
<Tooltip content={t("to_do")} position="bottom"> <Tooltip content={t("to_do")} position="bottom">
<button <button
className="py-1 px-2 hover-2 rounded-sm text-xl -mt-0.5" className="py-1 px-2 hover-2 rounded-sm text-xl -mt-0.5"
@@ -1743,7 +1787,7 @@ export default function ControlPanel({
/> />
)} )}
<div <div
className="text-xl me-1" className="text-xl flex items-center gap-1 me-1"
onPointerEnter={(e) => e.isPrimary && setShowEditName(true)} onPointerEnter={(e) => e.isPrimary && setShowEditName(true)}
onPointerLeave={(e) => e.isPrimary && setShowEditName(false)} onPointerLeave={(e) => e.isPrimary && setShowEditName(false)}
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -1751,14 +1795,24 @@ export default function ControlPanel({
// https://stackoverflow.com/a/70976017/1137077 // https://stackoverflow.com/a/70976017/1137077
e.target.releasePointerCapture(e.pointerId); e.target.releasePointerCapture(e.pointerId);
}} }}
onClick={() => setModal(MODAL.RENAME)} onClick={!layout.readOnly && (() => setModal(MODAL.RENAME))}
> >
{window.name.split(" ")[0] === "t" ? "Templates/" : "Diagrams/"} <span>
{title} {(window.name.split(" ")[0] === "t"
? "Templates/"
: "Diagrams/") + title}
</span>
{version && (
<Tag className="mt-1" color="blue" size="small">
{version.substring(0, 7)}
</Tag>
)}
</div> </div>
{(showEditName || modal === MODAL.RENAME) && <IconEdit />} {(showEditName || modal === MODAL.RENAME) && !layout.readOnly && (
<IconEdit />
)}
</div> </div>
<div className="flex justify-between items-center"> <div className="flex items-center">
<div className="flex justify-start text-md select-none me-2"> <div className="flex justify-start text-md select-none me-2">
{Object.keys(menu).map((category) => ( {Object.keys(menu).map((category) => (
<Dropdown <Dropdown
@@ -1785,6 +1839,7 @@ export default function ControlPanel({
key={i} key={i}
onClick={e.function} onClick={e.function}
className="flex justify-between" className="flex justify-between"
disabled={e.disabled}
> >
<span>{e.name}</span> <span>{e.name}</span>
{e.label && ( {e.label && (
@@ -1838,6 +1893,7 @@ export default function ControlPanel({
return ( return (
<Dropdown.Item <Dropdown.Item
key={index} key={index}
disabled={menu[category][item].disabled}
onClick={menu[category][item].function} onClick={menu[category][item].function}
style={ style={
menu[category][item].shortcut && { menu[category][item].shortcut && {
@@ -1871,17 +1927,21 @@ export default function ControlPanel({
</Dropdown> </Dropdown>
))} ))}
</div> </div>
<Button {layout.readOnly && <Tag size="small">{t("read_only")}</Tag>}
{!layout.readOnly && (
<Tag
size="small" size="small"
type="tertiary" type="light"
icon={ prefixIcon={
saveState === State.LOADING || saveState === State.SAVING ? ( saveState === State.LOADING ||
saveState === State.SAVING ? (
<Spin size="small" /> <Spin size="small" />
) : null ) : null
} }
> >
{getState()} {getState()}
</Button> </Tag>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,17 @@
import { Input } from "@douyinfe/semi-ui"; import { Input } from "@douyinfe/semi-ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLayout } from "../../../hooks";
export default function Rename({ title, setTitle }) { export default function Rename({ title, setTitle }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
return ( return (
<Input <Input
placeholder={t("name")} placeholder={t("name")}
defaultValue={title} defaultValue={title}
onChange={(v) => setTitle(v)} onChange={(v) => setTitle(v)}
readonly={layout.readOnly}
/> />
); );
} }

View File

@@ -1,13 +1,15 @@
import { InputNumber } from "@douyinfe/semi-ui"; import { InputNumber } from "@douyinfe/semi-ui";
import { useSettings } from "../../../hooks"; import { useLayout, useSettings } from "../../../hooks";
export default function SetTableWidth() { export default function SetTableWidth() {
const { layout } = useLayout();
const { settings, setSettings } = useSettings(); const { settings, setSettings } = useSettings();
return ( return (
<InputNumber <InputNumber
className="w-full" className="w-full"
value={settings.tableWidth} value={settings.tableWidth}
readonly={layout.readOnly}
onChange={(c) => { onChange={(c) => {
if (c < 180) return; if (c < 180) return;
setSettings((prev) => ({ ...prev, tableWidth: c })); setSettings((prev) => ({ ...prev, tableWidth: c }));

View File

@@ -13,7 +13,7 @@ import {
} from "../../../hooks"; } from "../../../hooks";
import { databases } from "../../../data/databases"; import { databases } from "../../../data/databases";
import { MODAL } from "../../../data/constants"; import { MODAL } from "../../../data/constants";
import { create, del, patch } from "../../../api/gists"; import { create, patch, SHARE_FILENAME } from "../../../api/gists";
export default function Share({ title, setModal }) { export default function Share({ title, setModal }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -55,24 +55,26 @@ export default function Share({ title, setModal }) {
const unshare = useCallback(async () => { const unshare = useCallback(async () => {
try { try {
await del(gistId); const deleted = await patch(gistId, SHARE_FILENAME, undefined);
if (deleted) {
setGistId(""); setGistId("");
}
setModal(MODAL.NONE); setModal(MODAL.NONE);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError(e); setError(e);
} }
}, [gistId, setGistId, setModal]); }, [gistId, setModal, setGistId]);
useEffect(() => { useEffect(() => {
const updateOrGenerateLink = async () => { const updateOrGenerateLink = async () => {
try { try {
setLoading(true); setLoading(true);
if (!gistId || gistId === "") { if (!gistId || gistId === "") {
const id = await create(diagramToString()); const id = await create(SHARE_FILENAME, diagramToString());
setGistId(id); setGistId(id);
} else { } else {
await patch(gistId, diagramToString()); await patch(gistId, SHARE_FILENAME, diagramToString());
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -116,7 +118,7 @@ export default function Share({ title, setModal }) {
{!error && ( {!error && (
<> <>
<div className="flex gap-3"> <div className="flex gap-3">
<Input value={url} size="large" /> <Input value={url} size="large" readonly />
</div> </div>
<div className="text-xs mt-2">{t("share_info")}</div> <div className="text-xs mt-2">{t("share_info")}</div>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">

View File

@@ -1,37 +1,21 @@
import { SideSheet as SemiUISideSheet } from "@douyinfe/semi-ui"; import { SideSheet as SemiUISideSheet } from "@douyinfe/semi-ui";
import { SIDESHEET } from "../../../data/constants"; import { SIDESHEET } from "../../../data/constants";
import { useSettings } from "../../../hooks";
import timeLine from "../../../assets/process.png";
import timeLineDark from "../../../assets/process_dark.png";
import todo from "../../../assets/calendar.png";
import Timeline from "./Timeline"; import Timeline from "./Timeline";
import Todo from "./Todo"; import Todo from "./Todo";
import Versions from "./Versions";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function Sidesheet({ type, onClose }) { export default function Sidesheet({ type, title, setTitle, onClose }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
function getTitle(type) { function getTitle(type) {
switch (type) { switch (type) {
case SIDESHEET.TIMELINE: case SIDESHEET.TIMELINE:
return ( return t("timeline");
<div className="flex items-center">
<img
src={settings.mode === "light" ? timeLine : timeLineDark}
className="w-7"
alt="chat icon"
/>
<div className="ms-3 text-lg">{t("timeline")}</div>
</div>
);
case SIDESHEET.TODO: case SIDESHEET.TODO:
return ( return t("to_do");
<div className="flex items-center"> case SIDESHEET.VERSIONS:
<img src={todo} className="w-7" alt="todo icon" /> return t("versions");
<div className="ms-3 text-lg">{t("to_do")}</div>
</div>
);
default: default:
break; break;
} }
@@ -43,6 +27,14 @@ export default function Sidesheet({ type, onClose }) {
return <Timeline />; return <Timeline />;
case SIDESHEET.TODO: case SIDESHEET.TODO:
return <Todo />; return <Todo />;
case SIDESHEET.VERSIONS:
return (
<Versions
open={type !== SIDESHEET.NONE}
title={title}
setTitle={setTitle}
/>
);
default: default:
break; break;
} }
@@ -52,12 +44,12 @@ export default function Sidesheet({ type, onClose }) {
<SemiUISideSheet <SemiUISideSheet
visible={type !== SIDESHEET.NONE} visible={type !== SIDESHEET.NONE}
onCancel={onClose} onCancel={onClose}
width={340} width={420}
title={getTitle(type)} title={<div className="text-lg">{getTitle(type)}</div>}
style={{ paddingBottom: "16px" }} style={{ paddingBottom: "16px" }}
bodyStyle={{ padding: "0px" }} bodyStyle={{ padding: "0px" }}
> >
{getContent(type)} <div className="sidesheet-theme">{getContent(type)}</div>
</SemiUISideSheet> </SemiUISideSheet>
); );
} }

View File

@@ -8,7 +8,7 @@ export default function Timeline() {
if (undoStack.length > 0) { if (undoStack.length > 0) {
return ( return (
<List className="sidesheet-theme"> <List>
{[...undoStack].reverse().map((e, i) => ( {[...undoStack].reverse().map((e, i) => (
<List.Item <List.Item
key={i} key={i}
@@ -24,6 +24,6 @@ export default function Timeline() {
</List> </List>
); );
} else { } else {
return <div className="m-5 sidesheet-theme">{t("no_activity")}</div>; return <div className="m-5">{t("no_activity")}</div>;
} }
} }

View File

@@ -106,7 +106,7 @@ export default function Todo() {
return ( return (
<> <>
<div className="flex justify-between items-center mx-5 mb-2 sidesheet-theme"> <div className="flex justify-between items-center mx-5 mb-2">
<Dropdown <Dropdown
render={ render={
<Dropdown.Menu> <Dropdown.Menu>
@@ -153,7 +153,7 @@ export default function Todo() {
</Button> </Button>
</div> </div>
{tasks.length > 0 ? ( {tasks.length > 0 ? (
<List className="sidesheet-theme"> <List>
{tasks.map((task, i) => ( {tasks.map((task, i) => (
<List.Item <List.Item
key={i} key={i}
@@ -267,7 +267,7 @@ export default function Todo() {
))} ))}
</List> </List>
) : ( ) : (
<div className="m-5 sidesheet-theme">{t("no_tasks")}</div> <div className="m-5">{t("no_tasks")}</div>
)} )}
</> </>
); );

View File

@@ -0,0 +1,320 @@
import { useCallback, useContext, useEffect, useState, useMemo } from "react";
import { IdContext } from "../../Workspace";
import { useTranslation } from "react-i18next";
import { Button, Spin, Steps, Tag, Toast } from "@douyinfe/semi-ui";
import { IconPlus } from "@douyinfe/semi-icons";
import {
create,
getCommitsWithFile,
getVersion,
patch,
get,
VERSION_FILENAME,
} from "../../../api/gists";
import _ from "lodash";
import { DateTime } from "luxon";
import {
useAreas,
useDiagram,
useEnums,
useLayout,
useNotes,
useTransform,
useTypes,
} from "../../../hooks";
import { databases } from "../../../data/databases";
const LIMIT = 10;
const STORAGE_KEY = "versions_cache";
function loadCache() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
function saveCache(cache) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
}
export default function Versions({ open, title, setTitle }) {
const { gistId, setGistId, version, setVersion } = useContext(IdContext);
const { areas, setAreas } = useAreas();
const { setLayout } = useLayout();
const { database, tables, relationships, setTables, setRelationships } =
useDiagram();
const { notes, setNotes } = useNotes();
const { types, setTypes } = useTypes();
const { enums, setEnums } = useEnums();
const { transform } = useTransform();
const { t, i18n } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [versions, setVersions] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState(null);
const [isRecording, setIsRecording] = useState(false);
const [loadingVersion, setLoadingVersion] = useState(null);
const cacheRef = useMemo(() => loadCache(), []);
const diagramToString = useCallback(() => {
return JSON.stringify({
title,
tables,
relationships: relationships,
notes: notes,
subjectAreas: areas,
database: database,
...(databases[database].hasTypes && { types: types }),
...(databases[database].hasEnums && { enums: enums }),
transform: transform,
});
}, [
areas,
notes,
tables,
relationships,
database,
title,
enums,
types,
transform,
]);
const currentStep = useMemo(() => {
if (!version) return 0;
return versions.findIndex((v) => v.version === version);
}, [version, versions]);
const loadVersion = useCallback(
async (sha) => {
try {
setLoadingVersion(sha);
const version = await getVersion(gistId, sha);
setVersion(sha);
setLayout((prev) => ({ ...prev, readOnly: true }));
if (!version.data.files[VERSION_FILENAME]) {
return;
}
const content = version.data.files[VERSION_FILENAME].content;
const parsedDiagram = JSON.parse(content);
setTables(parsedDiagram.tables);
setRelationships(parsedDiagram.relationships);
setAreas(parsedDiagram.subjectAreas);
setNotes(parsedDiagram.notes);
setTitle(parsedDiagram.title);
if (databases[database].hasTypes) {
setTypes(parsedDiagram.types);
}
if (databases[database].hasEnums) {
setEnums(parsedDiagram.enums);
}
} catch (e) {
Toast.error(t("failed_to_load_diagram"));
} finally {
setLoadingVersion(null);
}
},
[
t,
gistId,
setTables,
setRelationships,
setAreas,
setVersion,
setLayout,
database,
setNotes,
setTypes,
setEnums,
setTitle,
],
);
const getRevisions = useCallback(
async (cursorParam) => {
try {
if (!gistId) return;
setIsLoading(true);
const cached = cacheRef[gistId];
if (cached && !cursorParam) {
setVersions(cached.versions);
setCursor(cached.cursor);
setHasMore(cached.hasMore);
setIsLoading(false);
return;
}
const res = await getCommitsWithFile(
gistId,
VERSION_FILENAME,
LIMIT,
cursorParam,
);
const newVersions = cursorParam ? [...versions, ...res.data] : res.data;
setVersions(newVersions);
setHasMore(res.pagination.hasMore);
setCursor(res.pagination.cursor);
cacheRef[gistId] = {
versions: newVersions,
cursor: res.pagination.cursor,
hasMore: res.pagination.hasMore,
};
saveCache(cacheRef);
} catch (e) {
Toast.error(t("oops_smth_went_wrong"));
} finally {
setIsLoading(false);
}
},
[gistId, versions, t, cacheRef],
);
const hasDiagramChanged = async () => {
if (!gistId) return true;
const previousVersion = await get(gistId);
if (!previousVersion.data.files[VERSION_FILENAME]) {
return true;
}
const previousDiagram = JSON.parse(
previousVersion.data.files[VERSION_FILENAME]?.content,
);
const currentDiagram = {
title,
tables,
relationships: relationships,
notes: notes,
subjectAreas: areas,
database: database,
...(databases[database].hasTypes && { types: types }),
...(databases[database].hasEnums && { enums: enums }),
transform: transform,
};
return !_.isEqual(previousDiagram, currentDiagram);
};
const recordVersion = async () => {
try {
setIsRecording(true);
const hasChanges = await hasDiagramChanged();
if (!hasChanges) {
Toast.info(t("no_changes_to_record"));
return;
}
if (gistId) {
await patch(gistId, VERSION_FILENAME, diagramToString());
} else {
const id = await create(VERSION_FILENAME, diagramToString());
setGistId(id);
}
delete cacheRef[gistId];
saveCache(cacheRef);
await getRevisions();
} catch (e) {
Toast.error(t("failed_to_record_version"));
} finally {
setIsRecording(false);
}
};
const onClearCache = () => {
delete cacheRef[gistId];
saveCache(cacheRef);
Toast.success(t("cache_cleared"));
};
useEffect(() => {
if (gistId && open) {
getRevisions();
}
}, [gistId, open, getRevisions]);
return (
<div className="mx-5 relative h-full">
<div className="sticky top-0 z-10 sidesheet-theme pb-2 grid grid-cols-3 gap-2">
<Button
className={cacheRef[gistId] ? "col-span-2" : "col-span-3"}
block
icon={isRecording ? <Spin /> : <IconPlus />}
disabled={isLoading || isRecording}
onClick={recordVersion}
>
{t("record_version")}
</Button>
{cacheRef[gistId] && (
<Button block type="danger" onClick={onClearCache}>
{t("clear_cache")}
</Button>
)}
</div>
{(!gistId || !versions.length) && !isLoading && (
<div className="my-3">{t("no_saved_versions")}</div>
)}
{gistId && (
<div className="my-3 overflow-y-auto">
<Steps direction="vertical" type="basic" current={currentStep}>
{versions.map((r) => (
<Steps.Step
key={r.version}
onClick={() => loadVersion(r.version)}
className="group"
title={
<div className="flex justify-between items-center w-full">
<Tag>{r.version.substring(0, 7)}</Tag>
<span className="text-xs hidden group-hover:inline-block">
{t("click_to_view")}
</span>
</div>
}
description={`${t("commited_at")} ${DateTime.fromISO(
r.committed_at,
)
.setLocale(i18n.language)
.toLocaleString(DateTime.DATETIME_MED)}`}
icon={
r.version === loadingVersion ? (
<Spin size="small" />
) : (
<i className="text-sm fa-solid fa-asterisk ms-1" />
)
}
/>
))}
</Steps>
</div>
)}
{isLoading && !isRecording && (
<div className="text-blue-500 text-center my-3">
<Spin size="middle" />
<div>{t("loading")}</div>
</div>
)}
{hasMore && !isLoading && (
<div className="text-center">
<Button onClick={() => getRevisions(cursor)}>{t("load_more")}</Button>
</div>
)}
</div>
);
}

View File

@@ -2,12 +2,13 @@ import { useState, useRef } from "react";
import { Button, Input } from "@douyinfe/semi-ui"; import { Button, Input } from "@douyinfe/semi-ui";
import ColorPicker from "../ColorPicker"; import ColorPicker from "../ColorPicker";
import { IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconDeleteStroked } from "@douyinfe/semi-icons";
import { useAreas, useUndoRedo } from "../../../hooks"; import { useAreas, useLayout, useUndoRedo } from "../../../hooks";
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function AreaInfo({ data, i }) { export default function AreaInfo({ data, i }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const { deleteArea, updateArea } = useAreas(); const { deleteArea, updateArea } = useAreas();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const [editField, setEditField] = useState({}); const [editField, setEditField] = useState({});
@@ -53,6 +54,7 @@ export default function AreaInfo({ data, i }) {
<Input <Input
value={data.name} value={data.name}
placeholder={t("name")} placeholder={t("name")}
readonly={layout.readOnly}
onChange={(value) => updateArea(data.id, { name: value })} onChange={(value) => updateArea(data.id, { name: value })}
onFocus={(e) => setEditField({ name: e.target.value })} onFocus={(e) => setEditField({ name: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -77,12 +79,14 @@ export default function AreaInfo({ data, i }) {
<ColorPicker <ColorPicker
usePopover={true} usePopover={true}
value={data.color} value={data.color}
readOnly={layout.readOnly}
onChange={(color) => updateArea(i, { color })} onChange={(color) => updateArea(i, { color })}
onColorPick={(color) => handleColorPick(color)} onColorPick={(color) => handleColorPick(color)}
/> />
<Button <Button
icon={<IconDeleteStroked />}
type="danger" type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteArea(i, true)} onClick={() => deleteArea(i, true)}
/> />
</div> </div>

View File

@@ -1,13 +1,14 @@
import { Button } from "@douyinfe/semi-ui"; import { Button } from "@douyinfe/semi-ui";
import { IconPlus } from "@douyinfe/semi-icons"; import { IconPlus } from "@douyinfe/semi-icons";
import Empty from "../Empty"; import Empty from "../Empty";
import { useAreas } from "../../../hooks"; import { useAreas, useLayout } from "../../../hooks";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import AreaInfo from "./AreaDetails"; import AreaInfo from "./AreaDetails";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function AreasTab() { export default function AreasTab() {
const { areas, addArea } = useAreas(); const { areas, addArea } = useAreas();
const { layout } = useLayout();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -15,7 +16,12 @@ export default function AreasTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<SearchBar /> <SearchBar />
<div> <div>
<Button icon={<IconPlus />} block onClick={() => addArea()}> <Button
icon={<IconPlus />}
block
onClick={() => addArea()}
disabled={layout.readOnly}
>
{t("add_area")} {t("add_area")}
</Button> </Button>
</div> </div>

View File

@@ -2,8 +2,9 @@ import { ColorPicker as SemiColorPicker } from "@douyinfe/semi-ui";
import { useState } from "react"; import { useState } from "react";
export default function ColorPicker({ export default function ColorPicker({
children,
value, value,
readOnly,
children,
onChange, onChange,
onColorPick, onColorPick,
...props ...props
@@ -25,6 +26,7 @@ export default function ColorPicker({
{...props} {...props}
value={SemiColorPicker.colorStringToValue(value)} value={SemiColorPicker.colorStringToValue(value)}
onChange={({ hex: color }) => { onChange={({ hex: color }) => {
if (readOnly) return;
setPickedColor(color); setPickedColor(color);
onChange(color); onChange(color);
}} }}

View File

@@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Input, TagInput } from "@douyinfe/semi-ui"; import { Button, Input, TagInput } from "@douyinfe/semi-ui";
import { IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconDeleteStroked } from "@douyinfe/semi-icons";
import { useDiagram, useEnums, useUndoRedo } from "../../../hooks"; import { useDiagram, useEnums, useLayout, useUndoRedo } from "../../../hooks";
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function EnumDetails({ data, i }) { export default function EnumDetails({ data, i }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const { deleteEnum, updateEnum } = useEnums(); const { deleteEnum, updateEnum } = useEnums();
const { tables, updateField } = useDiagram(); const { tables, updateField } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
@@ -18,6 +19,7 @@ export default function EnumDetails({ data, i }) {
<div className="font-semibold">{t("Name")}: </div> <div className="font-semibold">{t("Name")}: </div>
<Input <Input
value={data.name} value={data.name}
readonly={layout.readOnly}
placeholder={t("name")} placeholder={t("name")}
validateStatus={data.name.trim() === "" ? "error" : "default"} validateStatus={data.name.trim() === "" ? "error" : "default"}
onChange={(value) => { onChange={(value) => {
@@ -71,7 +73,11 @@ export default function EnumDetails({ data, i }) {
className="my-2" className="my-2"
placeholder={t("values")} placeholder={t("values")}
validateStatus={data.values.length === 0 ? "error" : "default"} validateStatus={data.values.length === 0 ? "error" : "default"}
onChange={(v) => updateEnum(i, { values: v })} onChange={(v) => {
if (layout.readOnly) return;
updateEnum(i, { values: v });
}}
onFocus={() => setEditField({ values: data.values })} onFocus={() => setEditField({ values: data.values })}
onBlur={() => { onBlur={() => {
if (JSON.stringify(editField.values) === JSON.stringify(data.values)) if (JSON.stringify(editField.values) === JSON.stringify(data.values))
@@ -95,8 +101,9 @@ export default function EnumDetails({ data, i }) {
/> />
<Button <Button
block block
icon={<IconDeleteStroked />}
type="danger" type="danger"
icon={<IconDeleteStroked />}
disabled={layout.readOnly}
onClick={() => deleteEnum(i, true)} onClick={() => deleteEnum(i, true)}
> >
{t("delete")} {t("delete")}

View File

@@ -1,5 +1,5 @@
import { Button, Collapse } from "@douyinfe/semi-ui"; import { Button, Collapse } from "@douyinfe/semi-ui";
import { useEnums } from "../../../hooks"; import { useEnums, useLayout } from "../../../hooks";
import { IconPlus } from "@douyinfe/semi-icons"; import { IconPlus } from "@douyinfe/semi-icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
@@ -8,6 +8,7 @@ import Empty from "../Empty";
export default function EnumsTab() { export default function EnumsTab() {
const { enums, addEnum } = useEnums(); const { enums, addEnum } = useEnums();
const { layout } = useLayout();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -15,7 +16,12 @@ export default function EnumsTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<SearchBar /> <SearchBar />
<div> <div>
<Button icon={<IconPlus />} block onClick={() => addEnum()}> <Button
block
icon={<IconPlus />}
onClick={() => addEnum()}
disabled={layout.readOnly}
>
{t("add_enum")} {t("add_enum")}
</Button> </Button>
</div> </div>

View File

@@ -3,10 +3,11 @@ import { Button, Collapse, TextArea, Input } from "@douyinfe/semi-ui";
import ColorPicker from "../ColorPicker"; import ColorPicker from "../ColorPicker";
import { IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconDeleteStroked } from "@douyinfe/semi-icons";
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { useNotes, useUndoRedo } from "../../../hooks"; import { useLayout, useNotes, useUndoRedo } from "../../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function NoteInfo({ data, nid }) { export default function NoteInfo({ data, nid }) {
const { layout } = useLayout();
const { updateNote, deleteNote } = useNotes(); const { updateNote, deleteNote } = useNotes();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const [editField, setEditField] = useState({}); const [editField, setEditField] = useState({});
@@ -62,6 +63,7 @@ export default function NoteInfo({ data, nid }) {
<div className="font-semibold me-2 break-keep">{t("title")}:</div> <div className="font-semibold me-2 break-keep">{t("title")}:</div>
<Input <Input
value={data.title} value={data.title}
readonly={layout.readOnly}
placeholder={t("title")} placeholder={t("title")}
onChange={(value) => updateNote(data.id, { title: value })} onChange={(value) => updateNote(data.id, { title: value })}
onFocus={(e) => setEditField({ title: e.target.value })} onFocus={(e) => setEditField({ title: e.target.value })}
@@ -90,6 +92,7 @@ export default function NoteInfo({ data, nid }) {
placeholder={t("content")} placeholder={t("content")}
value={data.content} value={data.content}
autosize autosize
readonly={layout.readOnly}
onChange={(value) => { onChange={(value) => {
const textarea = document.getElementById(`note_${data.id}`); const textarea = document.getElementById(`note_${data.id}`);
textarea.style.height = "0"; textarea.style.height = "0";
@@ -127,13 +130,15 @@ export default function NoteInfo({ data, nid }) {
<div className="ms-2 flex flex-col gap-2"> <div className="ms-2 flex flex-col gap-2">
<ColorPicker <ColorPicker
usePopover={true} usePopover={true}
readOnly={layout.readOnly}
value={data.color} value={data.color}
onChange={(color) => updateNote(data.id, { color })} onChange={(color) => updateNote(data.id, { color })}
onColorPick={(color) => handleColorPick(color)} onColorPick={(color) => handleColorPick(color)}
/> />
<Button <Button
icon={<IconDeleteStroked />}
type="danger" type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteNote(nid, true)} onClick={() => deleteNote(nid, true)}
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Button, Collapse } from "@douyinfe/semi-ui"; import { Button, Collapse } from "@douyinfe/semi-ui";
import { IconPlus } from "@douyinfe/semi-icons"; import { IconPlus } from "@douyinfe/semi-icons";
import { useNotes, useSelect } from "../../../hooks"; import { useLayout, useNotes, useSelect } from "../../../hooks";
import Empty from "../Empty"; import Empty from "../Empty";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import NoteInfo from "./NoteInfo"; import NoteInfo from "./NoteInfo";
@@ -10,6 +10,7 @@ export default function NotesTab() {
const { notes, addNote } = useNotes(); const { notes, addNote } = useNotes();
const { selectedElement, setSelectedElement } = useSelect(); const { selectedElement, setSelectedElement } = useSelect();
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
return ( return (
<> <>
@@ -23,7 +24,12 @@ export default function NotesTab() {
} }
/> />
<div> <div>
<Button icon={<IconPlus />} block onClick={() => addNote()}> <Button
block
icon={<IconPlus />}
onClick={() => addNote()}
disabled={layout.readOnly}
>
{t("add_note")} {t("add_note")}
</Button> </Button>
</div> </div>

View File

@@ -18,7 +18,7 @@ import {
Action, Action,
ObjectType, ObjectType,
} from "../../../data/constants"; } from "../../../data/constants";
import { useDiagram, useUndoRedo } from "../../../hooks"; import { useDiagram, useLayout, useUndoRedo } from "../../../hooks";
import i18n from "../../../i18n/i18n"; import i18n from "../../../i18n/i18n";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -38,6 +38,7 @@ export default function RelationshipInfo({ data }) {
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const { tables, deleteRelationship, updateRelationship } = useDiagram(); const { tables, deleteRelationship, updateRelationship } = useDiagram();
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const [editField, setEditField] = useState({}); const [editField, setEditField] = useState({});
const relValues = useMemo(() => { const relValues = useMemo(() => {
@@ -98,6 +99,8 @@ export default function RelationshipInfo({ data }) {
}; };
const changeCardinality = (value) => { const changeCardinality = (value) => {
if (layout.readOnly) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
{ {
@@ -117,6 +120,8 @@ export default function RelationshipInfo({ data }) {
}; };
const changeConstraint = (key, value) => { const changeConstraint = (key, value) => {
if (layout.readOnly) return;
const undoKey = `${key}Constraint`; const undoKey = `${key}Constraint`;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -145,6 +150,7 @@ export default function RelationshipInfo({ data }) {
validateStatus={data.name.trim() === "" ? "error" : "default"} validateStatus={data.name.trim() === "" ? "error" : "default"}
placeholder={t("name")} placeholder={t("name")}
className="ms-2" className="ms-2"
readonly={layout.readOnly}
onChange={(value) => updateRelationship(data.id, { name: value })} onChange={(value) => updateRelationship(data.id, { name: value })}
onFocus={(e) => setEditField({ name: e.target.value })} onFocus={(e) => setEditField({ name: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -196,9 +202,10 @@ export default function RelationshipInfo({ data }) {
/> />
<div className="mt-2"> <div className="mt-2">
<Button <Button
icon={<IconLoopTextStroked />}
block block
icon={<IconLoopTextStroked />}
onClick={swapKeys} onClick={swapKeys}
disabled={layout.readOnly}
> >
{t("swap")} {t("swap")}
</Button> </Button>
@@ -234,7 +241,7 @@ export default function RelationshipInfo({ data }) {
placeholder={t("label")} placeholder={t("label")}
onChange={(value) => updateRelationship(data.id, { manyLabel: value })} onChange={(value) => updateRelationship(data.id, { manyLabel: value })}
onFocus={(e) => setEditField({ manyLabel: e.target.value })} onFocus={(e) => setEditField({ manyLabel: e.target.value })}
defaultValue="n" readonly={layout.readOnly}
onBlur={(e) => { onBlur={(e) => {
if (e.target.value === editField.manyLabel) return; if (e.target.value === editField.manyLabel) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
@@ -285,9 +292,10 @@ export default function RelationshipInfo({ data }) {
</Col> </Col>
</Row> </Row>
<Button <Button
icon={<IconDeleteStroked />}
block block
type="danger" type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteRelationship(data.id)} onClick={() => deleteRelationship(data.id)}
> >
{t("delete")} {t("delete")}

View File

@@ -9,13 +9,14 @@ import {
} from "@douyinfe/semi-ui"; } from "@douyinfe/semi-ui";
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconDeleteStroked } from "@douyinfe/semi-icons";
import { useDiagram, useUndoRedo } from "../../../hooks"; import { useDiagram, useLayout, useUndoRedo } from "../../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { dbToTypes } from "../../../data/datatypes"; import { dbToTypes } from "../../../data/datatypes";
import { databases } from "../../../data/databases"; import { databases } from "../../../data/databases";
export default function FieldDetails({ data, tid }) { export default function FieldDetails({ data, tid }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const { tables, database } = useDiagram(); const { tables, database } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const { updateField, deleteField } = useDiagram(); const { updateField, deleteField } = useDiagram();
@@ -29,6 +30,7 @@ export default function FieldDetails({ data, tid }) {
className="my-2" className="my-2"
placeholder={t("default_value")} placeholder={t("default_value")}
value={data.default} value={data.default}
readonly={layout.readOnly}
disabled={dbToTypes[database][data.type].noDefault || data.increment} disabled={dbToTypes[database][data.type].noDefault || data.increment}
onChange={(value) => updateField(tid, data.id, { default: value })} onChange={(value) => updateField(tid, data.id, { default: value })}
onFocus={(e) => setEditField({ default: e.target.value })} onFocus={(e) => setEditField({ default: e.target.value })}
@@ -67,7 +69,10 @@ export default function FieldDetails({ data, tid }) {
addOnBlur addOnBlur
className="my-2" className="my-2"
placeholder={t("use_for_batch_input")} placeholder={t("use_for_batch_input")}
onChange={(v) => updateField(tid, data.id, { values: v })} onChange={(v) => {
if (layout.readOnly) return;
updateField(tid, data.id, { values: v });
}}
onFocus={() => setEditField({ values: data.values })} onFocus={() => setEditField({ values: data.values })}
onBlur={() => { onBlur={() => {
if ( if (
@@ -102,6 +107,7 @@ export default function FieldDetails({ data, tid }) {
className="my-2 w-full" className="my-2 w-full"
placeholder={t("size")} placeholder={t("size")}
value={data.size} value={data.size}
readonly={layout.readOnly}
onChange={(value) => updateField(tid, data.id, { size: value })} onChange={(value) => updateField(tid, data.id, { size: value })}
onFocus={(e) => setEditField({ size: e.target.value })} onFocus={(e) => setEditField({ size: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -138,6 +144,7 @@ export default function FieldDetails({ data, tid }) {
? "default" ? "default"
: "error" : "error"
} }
readonly={layout.readOnly}
value={data.size} value={data.size}
onChange={(value) => updateField(tid, data.id, { size: value })} onChange={(value) => updateField(tid, data.id, { size: value })}
onFocus={(e) => setEditField({ size: e.target.value })} onFocus={(e) => setEditField({ size: e.target.value })}
@@ -172,6 +179,7 @@ export default function FieldDetails({ data, tid }) {
placeholder={t("check")} placeholder={t("check")}
value={data.check} value={data.check}
disabled={data.increment} disabled={data.increment}
readonly={layout.readOnly}
onChange={(value) => updateField(tid, data.id, { check: value })} onChange={(value) => updateField(tid, data.id, { check: value })}
onFocus={(e) => setEditField({ check: e.target.value })} onFocus={(e) => setEditField({ check: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -203,6 +211,7 @@ export default function FieldDetails({ data, tid }) {
<Checkbox <Checkbox
value="unique" value="unique"
checked={data.unique} checked={data.unique}
disabled={layout.readOnly}
onChange={(checkedValues) => { onChange={(checkedValues) => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -233,7 +242,7 @@ export default function FieldDetails({ data, tid }) {
value="increment" value="increment"
checked={data.increment} checked={data.increment}
disabled={ disabled={
!dbToTypes[database][data.type].canIncrement || data.isArray !dbToTypes[database][data.type].canIncrement || data.isArray || layout.readOnly
} }
onChange={(checkedValues) => { onChange={(checkedValues) => {
setUndoStack((prev) => [ setUndoStack((prev) => [
@@ -270,6 +279,7 @@ export default function FieldDetails({ data, tid }) {
<Checkbox <Checkbox
value="isArray" value="isArray"
checked={data.isArray} checked={data.isArray}
disabled={layout.readOnly}
onChange={(checkedValues) => { onChange={(checkedValues) => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -307,6 +317,7 @@ export default function FieldDetails({ data, tid }) {
<Checkbox <Checkbox
value="unsigned" value="unsigned"
checked={data.unsigned} checked={data.unsigned}
disabled={layout.readOnly}
onChange={(checkedValues) => { onChange={(checkedValues) => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -343,6 +354,7 @@ export default function FieldDetails({ data, tid }) {
className="my-2" className="my-2"
placeholder={t("comment")} placeholder={t("comment")}
value={data.comment} value={data.comment}
readonly={layout.readOnly}
autosize autosize
rows={2} rows={2}
onChange={(value) => updateField(tid, data.id, { comment: value })} onChange={(value) => updateField(tid, data.id, { comment: value })}
@@ -372,6 +384,7 @@ export default function FieldDetails({ data, tid }) {
icon={<IconDeleteStroked />} icon={<IconDeleteStroked />}
type="danger" type="danger"
block block
disabled={layout.readOnly}
onClick={() => deleteField(data, tid)} onClick={() => deleteField(data, tid)}
> >
{t("delete")} {t("delete")}

View File

@@ -1,12 +1,13 @@
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { Input, Button, Popover, Checkbox, Select } from "@douyinfe/semi-ui"; import { Input, Button, Popover, Checkbox, Select } from "@douyinfe/semi-ui";
import { IconMore, IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconMore, IconDeleteStroked } from "@douyinfe/semi-icons";
import { useDiagram, useUndoRedo } from "../../../hooks"; import { useDiagram, useLayout, useUndoRedo } from "../../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
export default function IndexDetails({ data, fields, iid, tid }) { export default function IndexDetails({ data, fields, iid, tid }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const { tables, updateTable } = useDiagram(); const { tables, updateTable } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const [editField, setEditField] = useState({}); const [editField, setEditField] = useState({});
@@ -22,6 +23,8 @@ export default function IndexDetails({ data, fields, iid, tid }) {
className="w-full" className="w-full"
value={data.fields} value={data.fields}
onChange={(value) => { onChange={(value) => {
if (layout.readOnly) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
{ {
@@ -62,6 +65,7 @@ export default function IndexDetails({ data, fields, iid, tid }) {
<Input <Input
value={data.name} value={data.name}
placeholder={t("name")} placeholder={t("name")}
readonly={layout.readOnly}
validateStatus={data.name.trim() === "" ? "error" : "default"} validateStatus={data.name.trim() === "" ? "error" : "default"}
onFocus={() => onFocus={() =>
setEditField({ setEditField({
@@ -106,6 +110,7 @@ export default function IndexDetails({ data, fields, iid, tid }) {
<Checkbox <Checkbox
value="unique" value="unique"
checked={data.unique} checked={data.unique}
disabled={layout.readOnly}
onChange={(checkedValues) => { onChange={(checkedValues) => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -145,9 +150,10 @@ export default function IndexDetails({ data, fields, iid, tid }) {
></Checkbox> ></Checkbox>
</div> </div>
<Button <Button
icon={<IconDeleteStroked />}
type="danger"
block block
type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => { onClick={() => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,

View File

@@ -2,7 +2,13 @@ import { useMemo, useState } from "react";
import { Action, ObjectType } from "../../../data/constants"; import { Action, ObjectType } from "../../../data/constants";
import { Input, Button, Popover, Select } from "@douyinfe/semi-ui"; import { Input, Button, Popover, Select } from "@douyinfe/semi-ui";
import { IconMore, IconKeyStroked } from "@douyinfe/semi-icons"; import { IconMore, IconKeyStroked } from "@douyinfe/semi-icons";
import { useEnums, useDiagram, useTypes, useUndoRedo } from "../../../hooks"; import {
useEnums,
useDiagram,
useTypes,
useUndoRedo,
useLayout,
} from "../../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { dbToTypes } from "../../../data/datatypes"; import { dbToTypes } from "../../../data/datatypes";
import { DragHandle } from "../../SortableList/DragHandle"; import { DragHandle } from "../../SortableList/DragHandle";
@@ -12,6 +18,7 @@ export default function TableField({ data, tid, index, inherited }) {
const { updateField } = useDiagram(); const { updateField } = useDiagram();
const { types } = useTypes(); const { types } = useTypes();
const { enums } = useEnums(); const { enums } = useEnums();
const { layout } = useLayout();
const { tables, database } = useDiagram(); const { tables, database } = useDiagram();
const { t } = useTranslation(); const { t } = useTranslation();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
@@ -20,7 +27,7 @@ export default function TableField({ data, tid, index, inherited }) {
return ( return (
<div className="hover-1 my-2 flex gap-2 items-center"> <div className="hover-1 my-2 flex gap-2 items-center">
<DragHandle id={data.id} /> <DragHandle readOnly={layout.readOnly} id={data.id} />
<div className="min-w-20 flex-1/3"> <div className="min-w-20 flex-1/3">
<Input <Input
@@ -29,6 +36,7 @@ export default function TableField({ data, tid, index, inherited }) {
validateStatus={ validateStatus={
data.name.trim() === "" || inherited ? "error" : "default" data.name.trim() === "" || inherited ? "error" : "default"
} }
readonly={layout.readOnly}
placeholder={t("name")} placeholder={t("name")}
onChange={(value) => updateField(tid, data.id, { name: value })} onChange={(value) => updateField(tid, data.id, { name: value })}
onFocus={(e) => setEditField({ name: e.target.value })} onFocus={(e) => setEditField({ name: e.target.value })}
@@ -77,6 +85,8 @@ export default function TableField({ data, tid, index, inherited }) {
validateStatus={data.type === "" ? "error" : "default"} validateStatus={data.type === "" ? "error" : "default"}
placeholder={t("type")} placeholder={t("type")}
onChange={(value) => { onChange={(value) => {
if (layout.readOnly) return;
if (value === data.type) return; if (value === data.type) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -142,10 +152,12 @@ export default function TableField({ data, tid, index, inherited }) {
<div> <div>
<Button <Button
type={data.notNull ? "tertiary" : "primary"}
title={t("nullable")} title={t("nullable")}
type={data.notNull ? "tertiary" : "primary"}
theme={data.notNull ? "light" : "solid"} theme={data.notNull ? "light" : "solid"}
onClick={() => { onClick={() => {
if (layout.readOnly) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
{ {
@@ -172,11 +184,13 @@ export default function TableField({ data, tid, index, inherited }) {
<div> <div>
<Button <Button
type={data.primary ? "primary" : "tertiary"}
title={t("primary")} title={t("primary")}
theme={data.primary ? "solid" : "light"} theme={data.primary ? "solid" : "light"}
type={data.primary ? "primary" : "tertiary"}
icon={<IconKeyStroked />} icon={<IconKeyStroked />}
onClick={() => { onClick={() => {
if (layout.readOnly) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
{ {

View File

@@ -9,7 +9,12 @@ import {
} from "@douyinfe/semi-ui"; } from "@douyinfe/semi-ui";
import ColorPicker from "../ColorPicker"; import ColorPicker from "../ColorPicker";
import { IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconDeleteStroked } from "@douyinfe/semi-icons";
import { useDiagram, useSaveState, useUndoRedo } from "../../../hooks"; import {
useDiagram,
useLayout,
useSaveState,
useUndoRedo,
} from "../../../hooks";
import { Action, ObjectType, State, DB } from "../../../data/constants"; import { Action, ObjectType, State, DB } from "../../../data/constants";
import TableField from "./TableField"; import TableField from "./TableField";
import IndexDetails from "./IndexDetails"; import IndexDetails from "./IndexDetails";
@@ -21,6 +26,7 @@ export default function TableInfo({ data }) {
const { tables, database } = useDiagram(); const { tables, database } = useDiagram();
const { t } = useTranslation(); const { t } = useTranslation();
const [indexActiveKey, setIndexActiveKey] = useState(""); const [indexActiveKey, setIndexActiveKey] = useState("");
const { layout } = useLayout();
const { deleteTable, updateTable, setTables } = useDiagram(); const { deleteTable, updateTable, setTables } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const { setSaveState } = useSaveState(); const { setSaveState } = useSaveState();
@@ -82,6 +88,7 @@ export default function TableInfo({ data }) {
validateStatus={data.name.trim() === "" ? "error" : "default"} validateStatus={data.name.trim() === "" ? "error" : "default"}
placeholder={t("name")} placeholder={t("name")}
className="ms-2" className="ms-2"
readonly={layout.readOnly}
onChange={(value) => updateTable(data.id, { name: value })} onChange={(value) => updateTable(data.id, { name: value })}
onFocus={(e) => setEditField({ name: e.target.value })} onFocus={(e) => setEditField({ name: e.target.value })}
onBlur={(e) => { onBlur={(e) => {
@@ -139,6 +146,8 @@ export default function TableInfo({ data }) {
.filter((t) => t.id !== data.id) .filter((t) => t.id !== data.id)
.map((t) => ({ label: t.name, value: t.name }))} .map((t) => ({ label: t.name, value: t.name }))}
onChange={(value) => { onChange={(value) => {
if (layout.readOnly) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
{ {
@@ -204,6 +213,7 @@ export default function TableInfo({ data }) {
<TextArea <TextArea
field="comment" field="comment"
value={data.comment} value={data.comment}
readonly={layout.readOnly}
autosize autosize
placeholder={t("comment")} placeholder={t("comment")}
rows={1} rows={1}
@@ -238,6 +248,7 @@ export default function TableInfo({ data }) {
<div className="flex justify-between items-center gap-1 mb-2"> <div className="flex justify-between items-center gap-1 mb-2">
<ColorPicker <ColorPicker
usePopover={true} usePopover={true}
readOnly={layout.readOnly}
value={data.color} value={data.color}
onChange={(color) => updateTable(data.id, { color })} onChange={(color) => updateTable(data.id, { color })}
onColorPick={(color) => handleColorPick(color)} onColorPick={(color) => handleColorPick(color)}
@@ -245,6 +256,7 @@ export default function TableInfo({ data }) {
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
block block
disabled={layout.readOnly}
onClick={() => { onClick={() => {
setIndexActiveKey("1"); setIndexActiveKey("1");
setUndoStack((prev) => [ setUndoStack((prev) => [
@@ -277,6 +289,8 @@ export default function TableInfo({ data }) {
{t("add_index")} {t("add_index")}
</Button> </Button>
<Button <Button
block
disabled={layout.readOnly}
onClick={() => { onClick={() => {
const id = nanoid(); const id = nanoid();
setUndoStack((prev) => [ setUndoStack((prev) => [
@@ -312,13 +326,13 @@ export default function TableInfo({ data }) {
], ],
}); });
}} }}
block
> >
{t("add_field")} {t("add_field")}
</Button> </Button>
<Button <Button
icon={<IconDeleteStroked />}
type="danger" type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteTable(data.id)} onClick={() => deleteTable(data.id)}
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Collapse, Button } from "@douyinfe/semi-ui"; import { Collapse, Button } from "@douyinfe/semi-ui";
import { IconPlus } from "@douyinfe/semi-icons"; import { IconPlus } from "@douyinfe/semi-icons";
import { useSelect, useDiagram, useSaveState } from "../../../hooks"; import { useSelect, useDiagram, useSaveState, useLayout } from "../../../hooks";
import { ObjectType, State } from "../../../data/constants"; import { ObjectType, State } from "../../../data/constants";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DragHandle } from "../../SortableList/DragHandle"; import { DragHandle } from "../../SortableList/DragHandle";
@@ -13,6 +13,7 @@ export default function TablesTab() {
const { tables, addTable, setTables } = useDiagram(); const { tables, addTable, setTables } = useDiagram();
const { selectedElement, setSelectedElement } = useSelect(); const { selectedElement, setSelectedElement } = useSelect();
const { t } = useTranslation(); const { t } = useTranslation();
const { layout } = useLayout();
const { setSaveState } = useSaveState(); const { setSaveState } = useSaveState();
return ( return (
@@ -20,7 +21,12 @@ export default function TablesTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<SearchBar tables={tables} /> <SearchBar tables={tables} />
<div> <div>
<Button icon={<IconPlus />} block onClick={() => addTable()}> <Button
block
icon={<IconPlus />}
onClick={() => addTable()}
disabled={layout.readOnly}
>
{t("add_table")} {t("add_table")}
</Button> </Button>
</div> </div>
@@ -60,6 +66,8 @@ export default function TablesTab() {
} }
function TableListItem({ table }) { function TableListItem({ table }) {
const { layout } = useLayout();
return ( return (
<div id={`scroll_table_${table.id}`}> <div id={`scroll_table_${table.id}`}>
<Collapse.Panel <Collapse.Panel
@@ -67,7 +75,7 @@ function TableListItem({ table }) {
header={ header={
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DragHandle id={table.id} /> <DragHandle readOnly={layout.readOnly} id={table.id} />
<div className="overflow-hidden text-ellipsis whitespace-nowrap"> <div className="overflow-hidden text-ellipsis whitespace-nowrap">
{table.name} {table.name}
</div> </div>

View File

@@ -11,13 +11,20 @@ import {
Popover, Popover,
} from "@douyinfe/semi-ui"; } from "@douyinfe/semi-ui";
import { IconDeleteStroked, IconMore } from "@douyinfe/semi-icons"; import { IconDeleteStroked, IconMore } from "@douyinfe/semi-icons";
import { useUndoRedo, useTypes, useDiagram, useEnums } from "../../../hooks"; import {
useUndoRedo,
useTypes,
useDiagram,
useEnums,
useLayout,
} from "../../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { dbToTypes } from "../../../data/datatypes"; import { dbToTypes } from "../../../data/datatypes";
export default function TypeField({ data, tid, fid }) { export default function TypeField({ data, tid, fid }) {
const { types, updateType } = useTypes(); const { types, updateType } = useTypes();
const { enums } = useEnums(); const { enums } = useEnums();
const { layout } = useLayout();
const { database } = useDiagram(); const { database } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
const [editField, setEditField] = useState({}); const [editField, setEditField] = useState({});
@@ -28,6 +35,7 @@ export default function TypeField({ data, tid, fid }) {
<Col span={10}> <Col span={10}>
<Input <Input
value={data.name} value={data.name}
readonly={layout.readOnly}
validateStatus={data.name === "" ? "error" : "default"} validateStatus={data.name === "" ? "error" : "default"}
placeholder={t("name")} placeholder={t("name")}
onChange={(value) => onChange={(value) =>
@@ -86,6 +94,7 @@ export default function TypeField({ data, tid, fid }) {
validateStatus={data.type === "" ? "error" : "default"} validateStatus={data.type === "" ? "error" : "default"}
placeholder={t("type")} placeholder={t("type")}
onChange={(value) => { onChange={(value) => {
if (layout.readOnly) return;
if (value === data.type) return; if (value === data.type) return;
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -160,13 +169,14 @@ export default function TypeField({ data, tid, fid }) {
} }
className="my-2" className="my-2"
placeholder={t("use_for_batch_input")} placeholder={t("use_for_batch_input")}
onChange={(v) => onChange={(v) => {
if (layout.readOnly) return;
updateType(tid, { updateType(tid, {
fields: types[tid].fields.map((e, id) => fields: types[tid].fields.map((e, id) =>
id === fid ? { ...data, values: v } : e, id === fid ? { ...data, values: v } : e,
), ),
}) });
} }}
onFocus={() => setEditField({ values: data.values })} onFocus={() => setEditField({ values: data.values })}
onBlur={() => { onBlur={() => {
if ( if (
@@ -202,6 +212,7 @@ export default function TypeField({ data, tid, fid }) {
className="my-2 w-full" className="my-2 w-full"
placeholder={t("size")} placeholder={t("size")}
value={data.size} value={data.size}
readonly={layout.readOnly}
onChange={(value) => onChange={(value) =>
updateType(tid, { updateType(tid, {
fields: types[tid].fields.map((e, id) => fields: types[tid].fields.map((e, id) =>
@@ -239,6 +250,7 @@ export default function TypeField({ data, tid, fid }) {
<Input <Input
className="my-2 w-full" className="my-2 w-full"
placeholder={t("set_precision")} placeholder={t("set_precision")}
readonly={layout.readOnly}
validateStatus={ validateStatus={
/^\(\d+,\s*\d+\)$|^$/.test(data.size) /^\(\d+,\s*\d+\)$|^$/.test(data.size)
? "default" ? "default"
@@ -277,9 +289,10 @@ export default function TypeField({ data, tid, fid }) {
</> </>
)} )}
<Button <Button
icon={<IconDeleteStroked />}
block block
type="danger" type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => { onClick={() => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,

View File

@@ -10,11 +10,12 @@ import {
Card, Card,
} from "@douyinfe/semi-ui"; } from "@douyinfe/semi-ui";
import { IconDeleteStroked, IconPlus } from "@douyinfe/semi-icons"; import { IconDeleteStroked, IconPlus } from "@douyinfe/semi-icons";
import { useUndoRedo, useTypes, useDiagram } from "../../../hooks"; import { useUndoRedo, useTypes, useDiagram, useLayout } from "../../../hooks";
import TypeField from "./TypeField"; import TypeField from "./TypeField";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function TypeInfo({ index, data }) { export default function TypeInfo({ index, data }) {
const { layout } = useLayout();
const { deleteType, updateType } = useTypes(); const { deleteType, updateType } = useTypes();
const { tables, updateField } = useDiagram(); const { tables, updateField } = useDiagram();
const { setUndoStack, setRedoStack } = useUndoRedo(); const { setUndoStack, setRedoStack } = useUndoRedo();
@@ -35,6 +36,7 @@ export default function TypeInfo({ index, data }) {
<div className="text-md font-semibold break-keep">{t("name")}: </div> <div className="text-md font-semibold break-keep">{t("name")}: </div>
<Input <Input
value={data.name} value={data.name}
readonly={layout.readOnly}
validateStatus={data.name === "" ? "error" : "default"} validateStatus={data.name === "" ? "error" : "default"}
placeholder={t("name")} placeholder={t("name")}
className="ms-2" className="ms-2"
@@ -97,6 +99,7 @@ export default function TypeInfo({ index, data }) {
field="comment" field="comment"
value={data.comment} value={data.comment}
autosize autosize
readonly={layout.readOnly}
placeholder={t("comment")} placeholder={t("comment")}
rows={1} rows={1}
onChange={(value) => onChange={(value) =>
@@ -130,6 +133,7 @@ export default function TypeInfo({ index, data }) {
<Col span={12}> <Col span={12}>
<Button <Button
icon={<IconPlus />} icon={<IconPlus />}
disabled={layout.readOnly}
onClick={() => { onClick={() => {
setUndoStack((prev) => [ setUndoStack((prev) => [
...prev, ...prev,
@@ -162,10 +166,11 @@ export default function TypeInfo({ index, data }) {
</Col> </Col>
<Col span={12}> <Col span={12}>
<Button <Button
icon={<IconDeleteStroked />}
type="danger"
onClick={() => deleteType(index)}
block block
type="danger"
disabled={layout.readOnly}
icon={<IconDeleteStroked />}
onClick={() => deleteType(index)}
> >
{t("delete")} {t("delete")}
</Button> </Button>

View File

@@ -1,6 +1,6 @@
import { Collapse, Button, Popover } from "@douyinfe/semi-ui"; import { Collapse, Button, Popover } from "@douyinfe/semi-ui";
import { IconPlus, IconInfoCircle } from "@douyinfe/semi-icons"; import { IconPlus, IconInfoCircle } from "@douyinfe/semi-icons";
import { useSelect, useDiagram, useTypes } from "../../../hooks"; import { useSelect, useDiagram, useTypes, useLayout } from "../../../hooks";
import { DB, ObjectType } from "../../../data/constants"; import { DB, ObjectType } from "../../../data/constants";
import Searchbar from "./SearchBar"; import Searchbar from "./SearchBar";
import Empty from "../Empty"; import Empty from "../Empty";
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
export default function TypesTab() { export default function TypesTab() {
const { types, addType } = useTypes(); const { types, addType } = useTypes();
const { selectedElement, setSelectedElement } = useSelect(); const { selectedElement, setSelectedElement } = useSelect();
const { layout } = useLayout();
const { database } = useDiagram(); const { database } = useDiagram();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +19,12 @@ export default function TypesTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<Searchbar /> <Searchbar />
<div> <div>
<Button icon={<IconPlus />} block onClick={() => addType()}> <Button
block
icon={<IconPlus />}
onClick={() => addType()}
disabled={layout.readOnly}
>
{t("add_type")} {t("add_type")}
</Button> </Button>
</div> </div>

View File

@@ -1,12 +1,13 @@
import { IconHandle } from "@douyinfe/semi-icons"; import { IconHandle } from "@douyinfe/semi-icons";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
export function DragHandle({ id }) { export function DragHandle({ id, readOnly }) {
const { listeners } = useSortable({ id }); const { listeners } = useSortable({ id });
return ( return (
<div <div
className="flex cursor-move items-center justify-center opacity-50 mt-0.5" className={`opacity-50 mt-0.5 ${readOnly ? "cursor-not-allowed" : "cursor-move"}`}
{...listeners} {...(!readOnly && listeners)}
> >
<IconHandle /> <IconHandle />
</div> </div>

View File

@@ -19,28 +19,36 @@ import {
useEnums, useEnums,
} from "../hooks"; } from "../hooks";
import FloatingControls from "./FloatingControls"; import FloatingControls from "./FloatingControls";
import { Modal, Tag } from "@douyinfe/semi-ui"; import { Button, Modal, Tag } from "@douyinfe/semi-ui";
import { IconAlertTriangle } from "@douyinfe/semi-icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { databases } from "../data/databases"; import { databases } from "../data/databases";
import { isRtl } from "../i18n/utils/rtl"; import { isRtl } from "../i18n/utils/rtl";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { get } from "../api/gists"; import { get, SHARE_FILENAME } from "../api/gists";
export const IdContext = createContext({ gistId: "", setGistId: () => {} }); export const IdContext = createContext({
gistId: "",
setGistId: () => {},
version: "",
setVersion: () => {},
});
const SIDEPANEL_MIN_WIDTH = 384; const SIDEPANEL_MIN_WIDTH = 384;
export default function WorkSpace() { export default function WorkSpace() {
const [id, setId] = useState(0); const [id, setId] = useState(0);
const [gistId, setGistId] = useState(""); const [gistId, setGistId] = useState("");
const [version, setVersion] = useState("");
const [loadedFromGistId, setLoadedFromGistId] = useState(""); const [loadedFromGistId, setLoadedFromGistId] = useState("");
const [title, setTitle] = useState("Untitled Diagram"); const [title, setTitle] = useState("Untitled Diagram");
const [resize, setResize] = useState(false); const [resize, setResize] = useState(false);
const [width, setWidth] = useState(SIDEPANEL_MIN_WIDTH); const [width, setWidth] = useState(SIDEPANEL_MIN_WIDTH);
const [lastSaved, setLastSaved] = useState(""); const [lastSaved, setLastSaved] = useState("");
const [showSelectDbModal, setShowSelectDbModal] = useState(false); const [showSelectDbModal, setShowSelectDbModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [selectedDb, setSelectedDb] = useState(""); const [selectedDb, setSelectedDb] = useState("");
const { layout } = useLayout(); const { layout, setLayout } = useLayout();
const { settings } = useSettings(); const { settings } = useSettings();
const { types, setTypes } = useTypes(); const { types, setTypes } = useTypes();
const { areas, setAreas } = useAreas(); const { areas, setAreas } = useAreas();
@@ -67,8 +75,6 @@ export default function WorkSpace() {
}; };
const save = useCallback(async () => { const save = useCallback(async () => {
if (saveState !== State.SAVING) return;
const name = window.name.split(" "); const name = window.name.split(" ");
const op = name[0]; const op = name[0];
const saveAsDiagram = window.name === "" || op === "d" || op === "lt"; const saveAsDiagram = window.name === "" || op === "d" || op === "lt";
@@ -163,7 +169,6 @@ export default function WorkSpace() {
enums, enums,
gistId, gistId,
loadedFromGistId, loadedFromGistId,
saveState,
]); ]);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -287,25 +292,24 @@ export default function WorkSpace() {
const loadFromGist = async (shareId) => { const loadFromGist = async (shareId) => {
try { try {
const res = await get(shareId); const { data } = await get(shareId);
const diagramSrc = res.data.files["share.json"].content; const parsedDiagram = JSON.parse(data.files[SHARE_FILENAME].content);
const d = JSON.parse(diagramSrc);
setGistId(shareId);
setUndoStack([]); setUndoStack([]);
setRedoStack([]); setRedoStack([]);
setGistId(shareId);
setLoadedFromGistId(shareId); setLoadedFromGistId(shareId);
setDatabase(d.database); setDatabase(parsedDiagram.database);
setTitle(d.title); setTitle(parsedDiagram.title);
setTables(d.tables); setTables(parsedDiagram.tables);
setRelationships(d.relationships); setRelationships(parsedDiagram.relationships);
setNotes(d.notes); setNotes(parsedDiagram.notes);
setAreas(d.subjectAreas); setAreas(parsedDiagram.subjectAreas);
setTransform(d.transform); setTransform(parsedDiagram.transform);
if (databases[d.database].hasTypes) { if (databases[parsedDiagram.database].hasTypes) {
setTypes(d.types ?? []); setTypes(parsedDiagram.types ?? []);
} }
if (databases[d.database].hasEnums) { if (databases[parsedDiagram.database].hasEnums) {
setEnums(d.enums ?? []); setEnums(parsedDiagram.enums ?? []);
} }
} catch (e) { } catch (e) {
console.log(e); console.log(e);
@@ -368,6 +372,12 @@ export default function WorkSpace() {
searchParams, searchParams,
]); ]);
const returnToCurrentDiagram = async () => {
await load();
setLayout((prev) => ({ ...prev, readOnly: false }));
setVersion(null);
};
useEffect(() => { useEffect(() => {
if ( if (
tables?.length === 0 && tables?.length === 0 &&
@@ -398,8 +408,12 @@ export default function WorkSpace() {
]); ]);
useEffect(() => { useEffect(() => {
if (layout.readOnly) return;
if (saveState !== State.SAVING) return;
save(); save();
}, [saveState, save]); }, [saveState, layout, save]);
useEffect(() => { useEffect(() => {
document.title = "Editor | drawDB"; document.title = "Editor | drawDB";
@@ -409,7 +423,7 @@ export default function WorkSpace() {
return ( return (
<div className="h-full flex flex-col overflow-hidden theme"> <div className="h-full flex flex-col overflow-hidden theme">
<IdContext.Provider value={{ gistId, setGistId }}> <IdContext.Provider value={{ gistId, setGistId, version, setVersion }}>
<ControlPanel <ControlPanel
diagramId={id} diagramId={id}
setDiagramId={setId} setDiagramId={setId}
@@ -438,6 +452,23 @@ export default function WorkSpace() {
<CanvasContextProvider className="h-full w-full"> <CanvasContextProvider className="h-full w-full">
<Canvas saveState={saveState} setSaveState={setSaveState} /> <Canvas saveState={saveState} setSaveState={setSaveState} />
</CanvasContextProvider> </CanvasContextProvider>
{version && (
<div className="absolute right-8 top-2 space-x-2">
<Button
icon={<i className="fa-solid fa-rotate-right mt-0.5"></i>}
onClick={() => setShowRestoreModal(true)}
>
{t("restore_version")}
</Button>
<Button
type="tertiary"
onClick={returnToCurrentDiagram}
icon={<i className="bi bi-arrow-return-right mt-1"></i>}
>
{t("return_to_current")}
</Button>
</div>
)}
{!(layout.sidebar || layout.toolbar || layout.header) && ( {!(layout.sidebar || layout.toolbar || layout.header) && (
<div className="fixed right-5 bottom-4"> <div className="fixed right-5 bottom-4">
<FloatingControls /> <FloatingControls />
@@ -494,6 +525,27 @@ export default function WorkSpace() {
))} ))}
</div> </div>
</Modal> </Modal>
<Modal
visible={showRestoreModal}
centered
closable
onCancel={() => setShowRestoreModal(false)}
title={
<span className="flex items-center gap-2">
<IconAlertTriangle className="text-amber-400" size="extra-large" />{" "}
{t("restore_version")}
</span>
}
okText={t("continue")}
cancelText={t("cancel")}
onOk={() => {
setLayout((prev) => ({ ...prev, readOnly: false }));
setShowRestoreModal(false);
setVersion(null);
}}
>
{t("restore_warning")}
</Modal>
</div> </div>
); );
} }

View File

@@ -9,6 +9,7 @@ export default function LayoutContextProvider({ children }) {
issues: true, issues: true,
toolbar: true, toolbar: true,
dbmlEditor: false, dbmlEditor: false,
readOnly: false,
}); });
return ( return (

View File

@@ -99,6 +99,7 @@ export const SIDESHEET = {
NONE: 0, NONE: 0,
TODO: 1, TODO: 1,
TIMELINE: 2, TIMELINE: 2,
VERSIONS: 3,
}; };
export const DB = { export const DB = {

View File

@@ -35,8 +35,8 @@ import { pl, polish } from "./locales/pl";
import { no, norwegian } from "./locales/no"; import { no, norwegian } from "./locales/no";
import { sv, swedish } from "./locales/sv-se"; import { sv, swedish } from "./locales/sv-se";
import { ur, urdu } from "./locales/ur"; import { ur, urdu } from "./locales/ur";
import { jp, japanese} from "./locales/jp" import { jp, japanese } from "./locales/jp";
import {ne, nepali} from "./locales/ne" import { ne, nepali } from "./locales/ne";
import { ug, uyghur } from "./locales/ug"; import { ug, uyghur } from "./locales/ug";
import { pa_pk, punjabipk } from "./locales/pa-pk"; import { pa_pk, punjabipk } from "./locales/pa-pk";
import { cz, czech } from "./locales/cz"; import { cz, czech } from "./locales/cz";
@@ -80,7 +80,7 @@ export const languages = [
nepali, nepali,
uyghur, uyghur,
punjabipk, punjabipk,
czech czech,
].sort((a, b) => a.name.localeCompare(b.name)); ].sort((a, b) => a.name.localeCompare(b.name));
i18n i18n
@@ -131,7 +131,7 @@ i18n
ne, ne,
ug, ug,
"pa-PK": pa_pk, "pa-PK": pa_pk,
cz cz,
}, },
}); });

View File

@@ -9,7 +9,8 @@ const en = {
report_bug: "Report a bug", report_bug: "Report a bug",
import: "Import", import: "Import",
inherits: "Inherits", inherits: "Inherits",
merging_column_w_inherited_definition: "Column '{{fieldName}}' in table '{{tableName}}' with inherited definition will be merged", merging_column_w_inherited_definition:
"Column '{{fieldName}}' in table '{{tableName}}' with inherited definition will be merged",
import_from: "Import from", import_from: "Import from",
file: "File", file: "File",
new: "New", new: "New",
@@ -258,6 +259,23 @@ const en = {
tab_view: "Tab view", tab_view: "Tab view",
label: "Label", label: "Label",
many_side_label: "Many(n) side label", many_side_label: "Many(n) side label",
version: "Version",
versions: "Versions",
no_saved_versions: "No saved versions",
record_version: "Record version",
commited_at: "Commited at",
read_only: "Read only",
continue: "Continue",
restore_version: "Restore version",
restore_warning: "Loading another version will overwrite any changes.",
return_to_current: "Return to diagram",
no_changes_to_record: "No changes to record",
click_to_view: "Click to view",
load_more: "Load more",
clear_cache: "Clear cache",
cache_cleared: "Cache cleared",
failed_to_record_version: "Failed to record version",
failed_to_load_diagram: "Failed to load diagram",
}, },
}; };

View File

@@ -67,6 +67,11 @@
background-color: rgba(var(--semi-blue-6), 1); background-color: rgba(var(--semi-blue-6), 1);
} }
.semi-steps-item-content,
.semi-steps-item-title {
width: 100% !important;
}
.semi-spin-wrapper { .semi-spin-wrapper {
color: inherit; color: inherit;
} }