import { React, useContext, useState } from "react";
import {
IconCaretdown,
IconChevronRight,
IconShareStroked,
IconChevronUp,
IconChevronDown,
IconCheckboxTick,
IconSaveStroked,
IconUndo,
IconRedo,
IconRowsStroked,
} from "@douyinfe/semi-icons";
import { Link } from "react-router-dom";
import icon from "../assets/icon_dark_64.png";
import {
Avatar,
AvatarGroup,
Button,
Divider,
Dropdown,
InputNumber,
Tooltip,
Image,
Modal,
Spin,
Input,
Upload,
Banner,
Toast,
} from "@douyinfe/semi-ui";
import { toPng, toJpeg, toSvg } from "html-to-image";
import { saveAs } from "file-saver";
import {
jsonDiagramIsValid,
enterFullscreen,
exitFullscreen,
ddbDiagramIsValid,
dataURItoBlob,
jsonToMySQL,
jsonToPostgreSQL,
} from "../utils";
import {
AreaContext,
LayoutContext,
NoteContext,
SelectContext,
SettingsContext,
TabContext,
TableContext,
TypeContext,
UndoRedoContext,
} from "../pages/editor";
import { IconAddTable, IconAddArea, IconAddNote } from "./custom_icons";
import { ObjectType, Action, Tab } from "../data/data";
import jsPDF from "jspdf";
import { useHotkeys } from "react-hotkeys-hook";
import { Validator } from "jsonschema";
import { areaSchema, noteSchema, tableSchema } from "../schemas";
import { Editor } from "@monaco-editor/react";
export default function ControlPanel(props) {
const MODAL = {
NONE: 0,
IMG: 1,
CODE: 2,
IMPORT: 3,
};
const STATUS = {
NONE: 0,
WARNING: 1,
ERROR: 2,
OK: 3,
};
const [visible, setVisible] = useState(MODAL.NONE);
const [exportData, setExportData] = useState({
data: null,
filename: `diagram_${new Date().toISOString()}`,
extension: "",
});
const [error, setError] = useState({
type: STATUS.NONE,
message: "",
});
const [data, setData] = useState(null);
const { layout, setLayout } = useContext(LayoutContext);
const { settings, setSettings } = useContext(SettingsContext);
const {
relationships,
tables,
setTables,
addTable,
updateTable,
deleteTable,
updateField,
setRelationships,
addRelationship,
deleteRelationship,
} = useContext(TableContext);
const { types, addType, deleteType, updateType, setTypes } =
useContext(TypeContext);
const { notes, setNotes, updateNote, addNote, deleteNote } =
useContext(NoteContext);
const { areas, setAreas, updateArea, addArea, deleteArea } =
useContext(AreaContext);
const { undoStack, redoStack, setUndoStack, setRedoStack } =
useContext(UndoRedoContext);
const { selectedElement, setSelectedElement } = useContext(SelectContext);
const { tab, setTab } = useContext(TabContext);
const invertLayout = (component) =>
setLayout((prev) => ({ ...prev, [component]: !prev[component] }));
const diagramIsEmpty = () => {
return (
tables.length === 0 &&
relationships.length === 0 &&
notes.length === 0 &&
areas.length === 0
);
};
const overwriteDiagram = () => {
setTables(data.tables);
setRelationships(data.relationships);
setAreas(data.subjectAreas);
setNotes(data.notes);
};
const undo = () => {
if (undoStack.length === 0) return;
const a = undoStack.pop();
if (a.action === Action.ADD) {
if (a.element === ObjectType.TABLE) {
deleteTable(tables[tables.length - 1].id, false);
} else if (a.element === ObjectType.AREA) {
deleteArea(areas[areas.length - 1].id, false);
} else if (a.element === ObjectType.NOTE) {
deleteNote(notes[notes.length - 1].id, false);
} else if (a.element === ObjectType.RELATIONSHIP) {
deleteRelationship(a.data.id, false);
} else if (a.element === ObjectType.TYPE) {
deleteType(types.length - 1, false);
}
setRedoStack((prev) => [...prev, a]);
} else if (a.action === Action.MOVE) {
if (a.element === ObjectType.TABLE) {
setRedoStack((prev) => [
...prev,
{ ...a, x: tables[a.id].x, y: tables[a.id].y },
]);
updateTable(a.id, { x: a.x, y: a.y }, true);
} else if (a.element === ObjectType.AREA) {
setRedoStack((prev) => [
...prev,
{ ...a, x: areas[a.id].x, y: areas[a.id].y },
]);
updateArea(a.id, { x: a.x, y: a.y });
} else if (a.element === ObjectType.NOTE) {
setRedoStack((prev) => [
...prev,
{ ...a, x: notes[a.id].x, y: notes[a.id].y },
]);
updateNote(a.id, { x: a.x, y: a.y });
}
} else if (a.action === Action.DELETE) {
if (a.element === ObjectType.TABLE) {
addTable(false, a.data);
} else if (a.element === ObjectType.RELATIONSHIP) {
addRelationship(false, a.data);
} else if (a.element === ObjectType.NOTE) {
addNote(false, a.data);
} else if (a.element === ObjectType.AREA) {
addArea(false, a.data);
} else if (a.element === ObjectType.TYPE) {
addType(false, { id: a.id, ...a.data });
}
setRedoStack((prev) => [...prev, a]);
} else if (a.action === Action.EDIT) {
if (a.element === ObjectType.AREA) {
updateArea(a.aid, a.undo);
} else if (a.element === ObjectType.NOTE) {
updateNote(a.nid, a.undo);
} else if (a.element === ObjectType.TABLE) {
if (a.component === "field") {
updateField(a.tid, a.fid, a.undo);
} else if (a.component === "field_delete") {
setTables((prev) =>
prev.map((t, i) => {
if (t.id === a.tid) {
const temp = t.fields.slice();
temp.splice(a.data.id, 0, a.data);
return { ...t, fields: temp.map((t, i) => ({ ...t, id: i })) };
}
return t;
})
);
} else if (a.component === "field_add") {
updateTable(a.tid, {
fields: tables[a.tid].fields
.filter((e) => e.id !== tables[a.tid].fields.length - 1)
.map((t, i) => ({ ...t, id: i })),
});
} else if (a.component === "index_add") {
updateTable(a.tid, {
indices: tables[a.tid].indices
.filter((e) => e.id !== tables[a.tid].indices.length - 1)
.map((t, i) => ({ ...t, id: i })),
});
} else if (a.component === "index") {
updateTable(a.tid, {
indices: tables[a.tid].indices.map((index) =>
index.id === a.iid
? {
...index,
...a.undo,
}
: index
),
});
} else if (a.component === "index_delete") {
setTables((prev) =>
prev.map((table, i) => {
if (table.id === a.tid) {
const temp = table.indices.slice();
temp.splice(a.data.id, 0, a.data);
return {
...table,
indices: temp.map((t, i) => ({ ...t, id: i })),
};
}
return table;
})
);
} else if (a.component === "self") {
updateTable(a.tid, a.undo);
}
} else if (a.element === ObjectType.RELATIONSHIP) {
setRelationships((prev) =>
prev.map((e, idx) => (idx === a.rid ? { ...e, ...a.undo } : e))
);
} else if (a.element === ObjectType.TYPE) {
if (a.component === "field_add") {
updateType(a.tid, {
fields: types[a.tid].fields.filter(
(e, i) => i !== types[a.tid].fields.length - 1
),
});
}
if (a.component === "field") {
updateType(a.tid, {
fields: types[a.tid].fields.map((e, i) =>
i === a.fid ? { ...e, ...a.undo } : e
),
});
} else if (a.component === "field_delete") {
setTypes((prev) =>
prev.map((t, i) => {
if (i === a.tid) {
const temp = t.fields.slice();
temp.splice(a.fid, 0, a.data);
return { ...t, fields: temp };
}
return t;
})
);
} else if (a.component === "self") {
updateType(a.tid, a.undo);
}
}
setRedoStack((prev) => [...prev, a]);
} else if (a.action === Action.PAN) {
setSettings((prev) => ({
...prev,
pan: a.undo,
}));
setRedoStack((prev) => [...prev, a]);
}
};
const redo = () => {
if (redoStack.length === 0) return;
const a = redoStack.pop();
if (a.action === Action.ADD) {
if (a.element === ObjectType.TABLE) {
addTable(false);
} else if (a.element === ObjectType.AREA) {
addArea(false);
} else if (a.element === ObjectType.NOTE) {
addNote(false);
} else if (a.element === ObjectType.RELATIONSHIP) {
addRelationship(false, a.data);
} else if (a.element === ObjectType.TYPE) {
addType(false);
}
setUndoStack((prev) => [...prev, a]);
} else if (a.action === Action.MOVE) {
if (a.element === ObjectType.TABLE) {
setUndoStack((prev) => [
...prev,
{ ...a, x: tables[a.id].x, y: tables[a.id].y },
]);
updateTable(a.id, { x: a.x, y: a.y }, true);
} else if (a.element === ObjectType.AREA) {
setUndoStack((prev) => [
...prev,
{ ...a, x: areas[a.id].x, y: areas[a.id].y },
]);
updateArea(a.id, { x: a.x, y: a.y });
} else if (a.element === ObjectType.NOTE) {
setUndoStack((prev) => [
...prev,
{ ...a, x: notes[a.id].x, y: notes[a.id].y },
]);
updateNote(a.id, { x: a.x, y: a.y });
}
} else if (a.action === Action.DELETE) {
if (a.element === ObjectType.TABLE) {
deleteTable(a.data.id, false);
} else if (a.element === ObjectType.RELATIONSHIP) {
deleteRelationship(a.data.id, false);
} else if (a.element === ObjectType.NOTE) {
deleteNote(a.data.id, false);
} else if (a.element === ObjectType.AREA) {
deleteArea(a.data.id, false);
} else if (a.element === ObjectType.TYPE) {
deleteType(a.id, false);
}
setUndoStack((prev) => [...prev, a]);
} else if (a.action === Action.EDIT) {
if (a.element === ObjectType.AREA) {
updateArea(a.aid, a.redo);
} else if (a.element === ObjectType.NOTE) {
updateNote(a.nid, a.redo);
} else if (a.element === ObjectType.TABLE) {
if (a.component === "field") {
updateField(a.tid, a.fid, a.redo);
} else if (a.component === "field_delete") {
updateTable(a.tid, {
fields: tables[a.tid].fields
.filter((field) => field.id !== a.data.id)
.map((e, i) => ({ ...e, id: i })),
});
} else if (a.component === "field_add") {
updateTable(a.tid, {
fields: [
...tables[a.tid].fields,
{
name: "",
type: "",
default: "",
check: "",
primary: false,
unique: false,
notNull: false,
increment: false,
comment: "",
id: tables[a.tid].fields.length,
},
],
});
} else if (a.component === "index_add") {
setTables((prev) =>
prev.map((table) => {
if (table.id === a.tid) {
return {
...table,
indices: [
...table.indices,
{
id: table.indices.length,
name: `index_${table.indices.length}`,
fields: [],
},
],
};
}
return table;
})
);
} else if (a.component === "index") {
updateTable(a.tid, {
indices: tables[a.tid].indices.map((index) =>
index.id === a.iid
? {
...index,
...a.redo,
}
: index
),
});
} else if (a.component === "index_delete") {
updateTable(a.tid, {
indices: tables[a.tid].indices
.filter((e) => e.id !== a.data.id)
.map((t, i) => ({ ...t, id: i })),
});
} else if (a.component === "self") {
updateTable(a.tid, a.redo, false);
}
} else if (a.element === ObjectType.RELATIONSHIP) {
setRelationships((prev) =>
prev.map((e, idx) => (idx === a.rid ? { ...e, ...a.redo } : e))
);
} else if (a.element === ObjectType.TYPE) {
if (a.component === "field_add") {
updateType(a.tid, {
fields: [
...types[a.tid].fields,
{
name: "",
type: "",
},
],
});
} else if (a.component === "field") {
updateType(a.tid, {
fields: types[a.tid].fields.map((e, i) =>
i === a.fid ? { ...e, ...a.redo } : e
),
});
} else if (a.component === "field_delete") {
updateType(a.tid, {
fields: types[a.tid].fields.filter((field, i) => i !== a.fid),
});
} else if (a.component === "self") {
updateType(a.tid, a.redo);
}
}
setUndoStack((prev) => [...prev, a]);
} else if (a.action === Action.PAN) {
setSettings((prev) => ({
...prev,
pan: a.redo,
}));
setUndoStack((prev) => [...prev, a]);
}
};
const fileImport = () => setVisible(MODAL.IMPORT);
const viewGrid = () =>
setSettings((prev) => ({ ...prev, showGrid: !prev.showGrid }));
const zoomIn = () =>
setSettings((prev) => ({ ...prev, zoom: prev.zoom * 1.2 }));
const zoomOut = () =>
setSettings((prev) => ({ ...prev, zoom: prev.zoom / 1.2 }));
const viewStrictMode = () => {
setSettings((prev) => ({ ...prev, strictMode: !prev.strictMode }));
Toast.success(`Stict mode is ${settings.strictMode ? "on" : "off"}.`);
};
const viewFieldSummary = () => {
setSettings((prev) => ({
...prev,
showFieldSummary: !prev.showFieldSummary,
}));
Toast.success(
`Field summary is ${settings.showFieldSummary ? "off" : "on"}.`
);
};
const copyAsImage = () => {
toPng(document.getElementById("canvas")).then(function (dataUrl) {
const blob = dataURItoBlob(dataUrl);
navigator.clipboard
.write([new ClipboardItem({ "image/png": blob })])
.then(() => {
Toast.success("Copied to clipboard.");
})
.catch((e) => {
Toast.error("Could not copy to clipboard.");
});
});
};
const resetView = () =>
setSettings((prev) => ({ ...prev, zoom: 1, pan: { x: 0, y: 0 } }));
const fitWindow = () => {
const diagram = document.getElementById("diagram").getBoundingClientRect();
const canvas = document.getElementById("canvas").getBoundingClientRect();
const scaleX = canvas.width / diagram.width;
const scaleY = canvas.height / diagram.height;
const scale = Math.min(scaleX, scaleY);
const translateX = canvas.width / 2;
const translateY = canvas.height / 2;
setSettings((prev) => ({
...prev,
zoom: scale,
pan: { x: translateX, y: translateY },
}));
};
const edit = () => {
if (selectedElement.element === ObjectType.TABLE) {
if (!layout.sidebar) {
setSelectedElement({
element: ObjectType.TABLE,
id: selectedElement.id,
openDialogue: true,
openCollapse: false,
});
} else {
setTab(Tab.tables);
setSelectedElement({
element: ObjectType.TABLE,
id: selectedElement.id,
openDialogue: false,
openCollapse: true,
});
if (tab !== Tab.tables) return;
document
.getElementById(`scroll_table_${selectedElement.id}`)
.scrollIntoView({ behavior: "smooth" });
}
} else if (selectedElement.element === ObjectType.AREA) {
if (layout.sidebar) {
setTab(Tab.subject_areas);
if (tab !== Tab.subject_areas) return;
document
.getElementById(`scroll_area_${selectedElement.id}`)
.scrollIntoView({ behavior: "smooth" });
} else {
setSelectedElement({
element: ObjectType.AREA,
id: selectedElement.id,
openDialogue: true,
openCollapse: false,
});
}
} else if (selectedElement.element === ObjectType.NOTE) {
if (layout.sidebar) {
setTab(Tab.notes);
if (tab !== Tab.notes) return;
document
.getElementById(`scroll_note_${selectedElement.id}`)
.scrollIntoView({ behavior: "smooth" });
} else {
setSelectedElement({
element: ObjectType.NOTE,
id: selectedElement.id,
openDialogue: true,
openCollapse: false,
});
}
}
};
const del = () => {
switch (selectedElement.element) {
case ObjectType.TABLE:
deleteTable(selectedElement.id, true);
break;
case ObjectType.NOTE:
deleteNote(selectedElement.id, true);
break;
case ObjectType.AREA:
deleteArea(selectedElement.id, true);
break;
default:
break;
}
};
const duplicate = () => {
switch (selectedElement.element) {
case ObjectType.TABLE:
addTable(true, {
...tables[selectedElement.id],
x: tables[selectedElement.id].x + 20,
y: tables[selectedElement.id].y + 20,
id: tables.length,
});
break;
case ObjectType.NOTE:
addNote(true, {
...notes[selectedElement.id],
x: notes[selectedElement.id].x + 20,
y: notes[selectedElement.id].y + 20,
id: notes.length,
});
break;
case ObjectType.AREA:
addArea(true, {
...areas[selectedElement.id],
x: areas[selectedElement.id].x + 20,
y: areas[selectedElement.id].y + 20,
id: areas.length,
});
break;
default:
break;
}
};
const copy = () => {
switch (selectedElement.element) {
case ObjectType.TABLE:
navigator.clipboard
.writeText(JSON.stringify({ ...tables[selectedElement.id] }))
.catch((e) => {
Toast.error("Could not copy");
});
break;
case ObjectType.NOTE:
navigator.clipboard
.writeText(JSON.stringify({ ...notes[selectedElement.id] }))
.catch((e) => {
Toast.error("Could not copy");
});
break;
case ObjectType.AREA:
navigator.clipboard
.writeText(JSON.stringify({ ...areas[selectedElement.id] }))
.catch((e) => {
Toast.error("Could not copy");
});
break;
default:
break;
}
};
const paste = () => {
navigator.clipboard.readText().then((text) => {
let obj = null;
try {
obj = JSON.parse(text);
} catch (error) {
return;
}
const v = new Validator();
if (v.validate(obj, tableSchema).valid) {
addTable(true, {
...obj,
x: obj.x + 20,
y: obj.y + 20,
id: tables.length,
});
} else if (v.validate(obj, areaSchema).valid) {
addArea(true, {
...obj,
x: obj.x + 20,
y: obj.y + 20,
id: areas.length,
});
} else if (v.validate(obj, noteSchema)) {
addNote(true, {
...obj,
x: obj.x + 20,
y: obj.y + 20,
id: notes.length,
});
}
});
};
const cut = () => {
copy();
del();
};
const menu = {
File: {
New: {
function: () => {},
},
"New window": {
function: () => {},
},
Save: {
function: () => {},
},
"Save as": {
function: () => {},
},
Share: {
function: () => {},
},
Rename: {
function: () => {},
},
Import: {
function: fileImport,
shortcut: "Ctrl+I",
},
"Export as": {
children: [
{
PNG: () => {
toPng(document.getElementById("canvas")).then(function (dataUrl) {
setExportData((prev) => ({
...prev,
data: dataUrl,
extension: "png",
}));
});
setVisible(MODAL.IMG);
},
},
{
JPEG: () => {
toJpeg(document.getElementById("canvas"), { quality: 0.95 }).then(
function (dataUrl) {
setExportData((prev) => ({
...prev,
data: dataUrl,
extension: "jpeg",
}));
}
);
setVisible(MODAL.IMG);
},
},
{
JSON: () => {
setVisible(MODAL.CODE);
const result = JSON.stringify(
{
tables: tables,
relationships: relationships,
notes: notes,
subjectAreas: areas,
types: types,
},
null,
2
);
setExportData((prev) => ({
...prev,
data: result,
extension: "json",
}));
},
},
{
SVG: () => {
const filter = (node) => node.tagName !== "i";
toSvg(document.getElementById("canvas"), { filter: filter }).then(
function (dataUrl) {
setExportData((prev) => ({
...prev,
data: dataUrl,
extension: "svg",
}));
}
);
setVisible(MODAL.IMG);
},
},
{
PDF: () => {
const canvas = document.getElementById("canvas");
toJpeg(canvas).then(function (dataUrl) {
const doc = new jsPDF("l", "px", [
canvas.offsetWidth,
canvas.offsetHeight,
]);
doc.addImage(
dataUrl,
"jpeg",
0,
0,
canvas.offsetWidth,
canvas.offsetHeight
);
doc.save(`${exportData.filename}.pdf`);
});
},
},
{
DRAWDB: () => {
const result = JSON.stringify(
{
author: "Unnamed",
project: "Untitled",
filename: "Untitled",
date: new Date().toISOString(),
tables: tables,
relationships: relationships,
notes: notes,
subjectAreas: areas,
types: types,
},
null,
2
);
const blob = new Blob([result], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, `${exportData.filename}.ddb`);
},
},
],
function: () => {},
},
"Export source": {
children: [
{
MySQL: () => {
setVisible(MODAL.CODE);
const src = jsonToMySQL({
tables: tables,
references: relationships,
types: types,
});
setExportData((prev) => ({
...prev,
data: src,
extension: "sql",
}));
},
},
{
PostgreSQL: () => {
setVisible(MODAL.CODE);
const src = jsonToPostgreSQL({
tables: tables,
references: relationships,
types: types,
});
setExportData((prev) => ({
...prev,
data: src,
extension: "sql",
}));
},
},
{ DBML: () => {} },
],
function: () => {},
},
Properties: {
function: () => {},
},
Close: {
function: () => {},
},
},
Edit: {
Undo: {
function: undo,
shortcut: "Ctrl+Z",
},
Redo: {
function: redo,
shortcut: "Ctrl+Y",
},
Clear: {
function: () => {
setTables([]);
setRelationships([]);
setAreas([]);
setNotes([]);
setUndoStack([]);
setRedoStack([]);
},
},
Edit: {
function: edit,
shortcut: "Ctrl+E",
},
Cut: {
function: cut,
shortcut: "Ctrl+X",
},
Copy: {
function: copy,
shortcut: "Ctrl+C",
},
Paste: {
function: paste,
shortcut: "Ctrl+V",
},
Duplicate: {
function: duplicate,
shortcut: "Ctrl+D",
},
Delete: {
function: del,
shortcut: "Del",
},
"Copy as image": {
function: copyAsImage,
shortcut: "Ctrl+Alt+C",
},
},
View: {
Header: {
function: () =>
setLayout((prev) => ({ ...prev, header: !prev.header })),
},
Sidebar: {
function: () =>
setLayout((prev) => ({ ...prev, sidebar: !prev.sidebar })),
},
Issues: {
function: () =>
setLayout((prev) => ({ ...prev, issues: !prev.issues })),
},
Services: {
function: () =>
setLayout((prev) => ({ ...prev, services: !prev.services })),
},
"Strict mode": {
function: viewStrictMode,
shortcut: "Ctrl+Shift+M",
},
"Field summary": {
function: viewFieldSummary,
shortcut: "Ctrl+Shift+F",
},
"Reset view": {
function: resetView,
shortcut: "Ctrl+R",
},
Grid: {
function: viewGrid,
shortcut: "Ctrl+Shift+G",
},
Theme: {
children: [
{
Light: () => {
const body = document.body;
if (body.hasAttribute("theme-mode")) {
body.setAttribute("theme-mode", "light");
}
setSettings((prev) => ({ ...prev, mode: "light" }));
},
},
{
Dark: () => {
const body = document.body;
if (body.hasAttribute("theme-mode")) {
body.setAttribute("theme-mode", "dark");
}
setSettings((prev) => ({ ...prev, mode: "dark" }));
},
},
],
function: () => {},
},
"Zoom in": {
function: zoomIn,
shortcut: "Ctrl+Up/Wheel",
},
"Zoom out": {
function: zoomOut,
shortcut: "Ctrl+Down/Wheel",
},
Fullscreen: {
function: enterFullscreen,
},
},
Logs: {
"Open logs": {
function: () => {},
},
"Commit changes": {
function: () => {},
},
"Revert changes": {
function: () => {},
},
"View commits": {
function: () => {},
},
},
Help: {
Shortcuts: {
function: () => {},
},
"Ask us on discord": {
function: () => {},
},
"Tweet us": {
function: () => {},
},
"Report a bug": {
function: () => {},
},
"Suggest a feature": {
function: () => {},
},
},
};
useHotkeys("ctrl+i, meta+i", fileImport, { preventDefault: true });
useHotkeys("ctrl+z, meta+z", undo, { preventDefault: true });
useHotkeys("ctrl+y, meta+y", redo, { preventDefault: true });
useHotkeys("ctrl+e, meta+e", edit, { preventDefault: true });
useHotkeys("ctrl+d, meta+d", duplicate, { preventDefault: true });
useHotkeys("ctrl+c, meta+c", copy, { preventDefault: true });
useHotkeys("ctrl+v, meta+v", paste, { preventDefault: true });
useHotkeys("ctrl+x, meta+x", cut, { preventDefault: true });
useHotkeys("delete", del, { preventDefault: true });
useHotkeys("ctrl+shift+g, meta+shift+g", viewGrid, { preventDefault: true });
useHotkeys("ctrl+up, meta+up", zoomIn, { preventDefault: true });
useHotkeys("ctrl+down, meta+down", zoomOut, { preventDefault: true });
useHotkeys("ctrl+shift+m, meta+shift+m", viewStrictMode, {
preventDefault: true,
});
useHotkeys("ctrl+shift+f, meta+shift+f", viewFieldSummary, {
preventDefault: true,
});
useHotkeys("ctrl+alt+c, meta+alt+c", copyAsImage, { preventDefault: true });
useHotkeys("ctrl+r, meta+r", resetView, { preventDefault: true });
useHotkeys("ctrl+alt+w, meta+alt+w", fitWindow, { preventDefault: true });
return (
<>
{layout.header && header()}
{toolbar()}