Merged PR 200: Implement the Canvas API rather than using SVG

This commit is contained in:
Eric Nguyen 2022-10-02 19:20:19 +00:00
parent af1b32c8d6
commit 04d79688cb
6 changed files with 712 additions and 11 deletions

View file

@ -0,0 +1,146 @@
import React, { useEffect, useRef } from 'react';
import { IPoint } from '../../Interfaces/IPoint';
import { BAR_WIDTH } from '../Bar/Bar';
interface ICanvasProps {
width: number
height: number
draw: (context: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint) => void
className?: string
style?: React.CSSProperties
};
function UseCanvas(draw: (context: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint) => void): React.RefObject<HTMLCanvasElement> {
const canvasRef = useRef<HTMLCanvasElement>(null);
const frameCount = useRef(0);
const translatePos = useRef({
x: 0,
y: 0
});
const startDragOffset = useRef({
x: 0,
y: 0
});
const scale = useRef(1.0);
const scaleMultiplier = useRef(0.8);
const dragging = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
let animationFrameId = 0;
if (canvas === null) {
return;
}
const context = canvas.getContext('2d');
// add event listeners to handle screen drag
function MouseDown(evt: MouseEvent): void {
dragging.current = true;
startDragOffset.current.x = evt.clientX - translatePos.current.x;
startDragOffset.current.y = evt.clientY - translatePos.current.y;
}
function MouseUp(evt: MouseEvent): void {
dragging.current = false;
}
function MouseMove(evt: MouseEvent): void {
if (!dragging.current || context === null) {
return;
}
translatePos.current.x = evt.clientX - startDragOffset.current.x;
translatePos.current.y = evt.clientY - startDragOffset.current.y;
frameCount.current++;
draw(context, frameCount.current, scale.current, translatePos.current);
}
function HandleScroll(evt: WheelEvent): void {
if (context === null) {
return;
}
if (evt.deltaY >= 0) {
scale.current *= scaleMultiplier.current;
} else {
scale.current /= scaleMultiplier.current;
}
draw(context, frameCount.current, scale.current, translatePos.current);
evt.preventDefault();
}
canvas.addEventListener('mousedown', MouseDown);
window.addEventListener('mouseup', MouseUp);
canvas.addEventListener('mousemove', MouseMove);
canvas.addEventListener('wheel', HandleScroll);
function Render(): void {
if (context === null) {
return;
}
frameCount.current++;
draw(context, frameCount.current, scale.current, translatePos.current);
animationFrameId = window.requestAnimationFrame(Render);
}
Render();
return () => {
window.cancelAnimationFrame(animationFrameId);
canvas.removeEventListener('mousedown', MouseDown);
window.removeEventListener('mouseup', MouseUp);
canvas.removeEventListener('mousemove', MouseMove);
canvas.removeEventListener('wheel', HandleScroll);
};
}, [draw]);
return canvasRef;
}
function UseSVGAutoResizer(
setViewer: React.Dispatch<React.SetStateAction<Viewer>>
): void {
React.useEffect(() => {
function OnResize(): void {
setViewer({
viewerWidth: window.innerWidth - BAR_WIDTH,
viewerHeight: window.innerHeight
});
}
window.addEventListener('resize', OnResize);
return () => {
window.removeEventListener('resize', OnResize);
};
});
}
interface Viewer {
viewerWidth: number
viewerHeight: number
}
export function Canvas({ width, height, draw, style, className }: ICanvasProps): JSX.Element {
const canvasRef = UseCanvas(draw);
const [{ viewerWidth, viewerHeight }, setViewer] = React.useState<Viewer>({
viewerWidth: width,
viewerHeight: height
});
UseSVGAutoResizer(setViewer);
return (
<canvas
ref={canvasRef}
style={style}
width={viewerWidth}
height={viewerHeight}
className={className}
/>
);
}

View file

@ -0,0 +1,82 @@
import { NOTCHES_LENGTH } from '../../utils/default';
interface IDimensionProps {
id: string
xStart: number
yStart: number
xEnd: number
yEnd: number
text: string
strokeWidth: number
scale?: number
}
/**
* 2D Parametric function. Returns a new coordinate from the origin coordinate
* See for more details https://en.wikipedia.org/wiki/Parametric_equation.
* TL;DR a parametric function is a function with a parameter
* @param x0 Origin coordinate
* @param t The parameter
* @param vx Transform vector
* @returns Returns a new coordinate from the origin coordinate
*/
function ApplyParametric(x0: number, t: number, vx: number): number {
return x0 + t * vx;
}
export function RenderDimension(ctx: CanvasRenderingContext2D, props: IDimensionProps): void {
const scale = props.scale ?? 1;
const strokeStyle = 'black';
const lineWidth = 2 / scale;
/// We need to find the points of the notches
// Get the vector of the line
const [deltaX, deltaY] = [(props.xEnd - props.xStart), (props.yEnd - props.yStart)];
const rotation = (Math.atan2(deltaY, deltaX) / (2 * Math.PI));
// Get the unit vector
const norm = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const [unitX, unitY] = [deltaX / norm, deltaY / norm];
// Get the perpandicular vector
const [perpVecX, perpVecY] = [unitY, -unitX];
// Use the parametric function to get the coordinates (x = x0 + t * v.x)
const notchesLength = NOTCHES_LENGTH / scale;
const startTopX = ApplyParametric(props.xStart, notchesLength, perpVecX);
const startTopY = ApplyParametric(props.yStart, notchesLength, perpVecY);
const startBottomX = ApplyParametric(props.xStart, -notchesLength, perpVecX);
const startBottomY = ApplyParametric(props.yStart, -notchesLength, perpVecY);
const endTopX = ApplyParametric(props.xEnd, notchesLength, perpVecX);
const endTopY = ApplyParametric(props.yEnd, notchesLength, perpVecY);
const endBottomX = ApplyParametric(props.xEnd, -notchesLength, perpVecX);
const endBottomY = ApplyParametric(props.yEnd, -notchesLength, perpVecY);
ctx.save();
ctx.globalAlpha = 1;
ctx.lineWidth = lineWidth;
ctx.strokeStyle = strokeStyle;
ctx.fillStyle = strokeStyle;
ctx.moveTo(startTopX, startTopY);
ctx.lineTo(startBottomX, startBottomY);
ctx.stroke();
ctx.moveTo(props.xStart, props.yStart);
ctx.lineTo(props.xEnd, props.yEnd);
ctx.stroke();
ctx.moveTo(endTopX, endTopY);
ctx.lineTo(endBottomX, endBottomY);
ctx.stroke();
const textX = (props.xStart + props.xEnd) / 2;
const textY = (props.yStart + props.yEnd) / 2;
ctx.font = `${16 / scale}px Verdana`;
ctx.textAlign = 'center';
ctx.globalAlpha = 1;
ctx.textBaseline = 'bottom';
ctx.translate(textX, textY);
ctx.rotate(rotation * Math.PI * 2);
ctx.fillText(props.text, 0, 0);
ctx.restore();
}

View file

@ -0,0 +1,345 @@
import { Orientation } from '../../Enums/Orientation';
import { Position } from '../../Enums/Position';
import { IContainerModel } from '../../Interfaces/IContainerModel';
import { SHOW_SELF_DIMENSIONS, SHOW_BORROWER_DIMENSIONS, SHOW_CHILDREN_DIMENSIONS } from '../../utils/default';
import { MakeRecursionDFSIterator, Pairwise } from '../../utils/itertools';
import { TransformX, TransformY } from '../../utils/svg';
import { RenderDimension } from './Dimension';
const MODULE_STROKE_WIDTH = 1;
export function AddDimensions(
ctx: CanvasRenderingContext2D,
container: IContainerModel,
dimMapped: number[],
currentTransform: [number, number],
scale: number,
depth: number
): void {
ctx.beginPath();
if (SHOW_SELF_DIMENSIONS && container.properties.showSelfDimensions.length > 0) {
ActionByPosition(
ctx,
dimMapped,
container.properties.showSelfDimensions,
AddHorizontalSelfDimension,
AddVerticalSelfDimension,
[container,
currentTransform,
scale]
);
}
if (SHOW_BORROWER_DIMENSIONS && container.properties.showDimensionWithMarks.length > 0) {
ActionByPosition(
ctx,
dimMapped,
container.properties.showDimensionWithMarks,
AddHorizontalBorrowerDimension,
AddVerticalBorrowerDimension,
[container,
depth,
currentTransform,
scale]
);
}
if (SHOW_CHILDREN_DIMENSIONS && container.properties.showChildrenDimensions.length > 0 && container.children.length >= 2) {
ActionByPosition(
ctx,
dimMapped,
container.properties.showChildrenDimensions,
AddHorizontalChildrenDimension,
AddVerticalChildrenDimension,
[container,
currentTransform,
scale]
);
}
}
/**
* Fonction that call another function given the positions
* @param dimMapped Position mapped depending on the Position enum in order:
* [0:left, 1:bottom, 2:up, 3:right]
* @param positions List of positions
* @param horizontalAction Action called when a left or right position is present
* @param verticalAction Action called when a down or up position is present
* @param params Params for the actions
* (the two actions must have the same number of params, and in the same order)
*/
function ActionByPosition(
ctx: CanvasRenderingContext2D,
dimMapped: number[],
positions: Position[],
horizontalAction: (ctx: CanvasRenderingContext2D, dim: number, ...params: any[]) => void,
verticalAction: (ctx: CanvasRenderingContext2D, dim: number, ...params: any[]) => void,
params: any[]
): void {
positions.forEach((position: Position) => {
const dim = dimMapped[position];
switch (position) {
case Position.Left:
case Position.Right:
verticalAction(ctx, dim, ...params);
break;
case Position.Down:
case Position.Up:
horizontalAction(ctx, dim, ...params);
break;
}
});
}
function AddHorizontalChildrenDimension(
ctx: CanvasRenderingContext2D,
yDim: number,
container: IContainerModel,
currentTransform: [number, number],
scale: number
): void {
const childrenId = `dim-y${yDim.toFixed(0)}-children-${container.properties.id}`;
const lastChild = container.children[container.children.length - 1];
let xChildrenStart = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.positionReference);
let xChildrenEnd = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.positionReference);
// Find the min and max
for (let i = container.children.length - 2; i >= 0; i--) {
const child = container.children[i];
const left = TransformX(child.properties.x, child.properties.width, child.properties.positionReference);
if (left < xChildrenStart) {
xChildrenStart = left;
}
const right = TransformX(child.properties.x, child.properties.width, child.properties.positionReference);
if (right > xChildrenEnd) {
xChildrenEnd = right;
}
}
if (xChildrenStart === xChildrenEnd) {
// do not show an empty dimension
return;
}
const textChildren = (xChildrenEnd - xChildrenStart)
.toFixed(2)
.toString();
const offset = currentTransform[0] + container.properties.x;
RenderDimension(ctx, {
id: childrenId,
xStart: xChildrenStart + offset,
xEnd: xChildrenEnd + offset,
yStart: yDim,
yEnd: yDim,
strokeWidth: MODULE_STROKE_WIDTH,
text: textChildren,
scale
});
}
function AddVerticalChildrenDimension(
ctx: CanvasRenderingContext2D,
xDim: number,
container: IContainerModel,
currentTransform: [number, number],
scale: number
): void {
const childrenId = `dim-x${xDim.toFixed(0)}-children-${container.properties.id}`;
const lastChild = container.children[container.children.length - 1];
let yChildrenStart = TransformY(lastChild.properties.y, lastChild.properties.height, lastChild.properties.positionReference);
let yChildrenEnd = TransformY(lastChild.properties.y, lastChild.properties.height, lastChild.properties.positionReference);
// Find the min and max
for (let i = container.children.length - 2; i >= 0; i--) {
const child = container.children[i];
const top = TransformY(child.properties.y, child.properties.height, child.properties.positionReference);
if (top < yChildrenStart) {
yChildrenStart = top;
}
const bottom = TransformY(child.properties.y, child.properties.height, child.properties.positionReference);
if (bottom > yChildrenEnd) {
yChildrenEnd = bottom;
}
}
if (yChildrenStart === yChildrenEnd) {
// do not show an empty dimension
return;
}
const textChildren = (yChildrenEnd - yChildrenStart)
.toFixed(2)
.toString();
const offset = currentTransform[0] + container.properties.x;
RenderDimension(ctx, {
id: childrenId,
xStart: xDim,
xEnd: xDim,
yStart: yChildrenStart + offset,
yEnd: yChildrenEnd + offset,
strokeWidth: MODULE_STROKE_WIDTH,
text: textChildren,
scale
});
}
function AddHorizontalBorrowerDimension(
ctx: CanvasRenderingContext2D,
yDim: number,
container: IContainerModel,
depth: number,
currentTransform: [number, number],
scale: number
): void {
const it = MakeRecursionDFSIterator(container, depth, currentTransform);
const marks = []; // list of vertical lines for the dimension
for (const {
container: childContainer, currentTransform: childCurrentTransform
} of it) {
const isHidden = !childContainer.properties.markPosition.includes(Orientation.Horizontal);
if (isHidden) {
continue;
}
const x = TransformX(
childContainer.properties.x,
childContainer.properties.width,
childContainer.properties.positionReference
);
const restoredX = x + childCurrentTransform[0];
marks.push(
restoredX
);
}
const restoredX = container.properties.x + currentTransform[0];
marks.push(restoredX);
marks.push(restoredX + container.properties.width);
marks.sort((a, b) => a - b);
let count = 0;
for (const { cur, next } of Pairwise(marks)) {
const id = `dim-y${yDim.toFixed(0)}-borrow-${container.properties.id}-{${count}}`;
RenderDimension(ctx, {
id,
xStart: cur,
xEnd: next,
yStart: yDim,
yEnd: yDim,
strokeWidth: MODULE_STROKE_WIDTH,
text: (next - cur).toFixed(0).toString(),
scale
});
count++;
}
}
function AddVerticalBorrowerDimension(
ctx: CanvasRenderingContext2D,
xDim: number,
container: IContainerModel,
depth: number,
currentTransform: [number, number],
scale: number
): void {
const it = MakeRecursionDFSIterator(container, depth, currentTransform);
const marks = []; // list of vertical lines for the dimension
for (const {
container: childContainer, currentTransform: childCurrentTransform
} of it) {
const isHidden = !childContainer.properties.markPosition.includes(Orientation.Vertical);
if (isHidden) {
continue;
}
const y = TransformY(
childContainer.properties.y,
childContainer.properties.height,
childContainer.properties.positionReference
);
const restoredy = y + childCurrentTransform[1];
marks.push(
restoredy
);
}
const restoredY = container.properties.y + currentTransform[1];
marks.push(restoredY);
marks.push(restoredY + container.properties.height);
marks.sort((a, b) => a - b);
let count = 0;
for (const { cur, next } of Pairwise(marks)) {
const id = `dim-x${xDim.toFixed(0)}-borrow-${container.properties.id}-{${count}}`;
RenderDimension(ctx, {
id,
xStart: xDim,
xEnd: xDim,
yStart: cur,
yEnd: next,
strokeWidth: MODULE_STROKE_WIDTH,
text: (next - cur).toFixed(0).toString(),
scale
});
count++;
}
}
function AddVerticalSelfDimension(
ctx: CanvasRenderingContext2D,
xDim: number,
container: IContainerModel,
currentTransform: [number, number],
scale: number
): void {
const height = container.properties.height;
const idVert = `dim-x${xDim.toFixed(0)}-${container.properties.id}`;
const yStart = container.properties.y + currentTransform[1];
const yEnd = yStart + height;
const textVert = height
.toFixed(0)
.toString();
RenderDimension(ctx, {
id: idVert,
xStart: xDim,
yStart,
xEnd: xDim,
yEnd,
strokeWidth: MODULE_STROKE_WIDTH,
text: textVert,
scale
});
}
function AddHorizontalSelfDimension(
ctx: CanvasRenderingContext2D,
yDim: number,
container: IContainerModel,
currentTransform: [number, number],
scale: number
): void {
const width = container.properties.width;
const id = `dim-y${yDim.toFixed(0)}-${container.properties.id}`;
const xStart = container.properties.x + currentTransform[0];
const xEnd = xStart + width;
const text = width
.toFixed(0)
.toString();
RenderDimension(ctx, {
id,
xStart,
yStart: yDim,
xEnd,
yEnd: yDim,
strokeWidth: MODULE_STROKE_WIDTH,
text,
scale
});
}

View file

@ -0,0 +1,57 @@
import { IContainerModel } from '../../Interfaces/IContainerModel';
import { SHOW_SELECTOR_TEXT } from '../../utils/default';
import { GetAbsolutePosition } from '../../utils/itertools';
import { RemoveMargin } from '../../utils/svg';
interface ISelectorProps {
selected?: IContainerModel
scale?: number
}
export function RenderSelector(ctx: CanvasRenderingContext2D, frameCount: number, props: ISelectorProps): void {
if (props.selected === undefined || props.selected === null) {
return;
}
const scale = (props.scale ?? 1);
let [x, y] = GetAbsolutePosition(props.selected);
let [width, height] = [
props.selected.properties.width,
props.selected.properties.height
];
({ x, y, width, height } = RemoveMargin(x, y, width, height,
props.selected.properties.margin.left,
props.selected.properties.margin.bottom,
props.selected.properties.margin.top,
props.selected.properties.margin.right
));
const xText = x + width / 2;
const yText = y + height / 2;
const style: React.CSSProperties = {
stroke: '#3B82F6',
strokeWidth: 4 / scale,
fillOpacity: 0,
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
animation: 'fadein 750ms ease-in alternate infinite'
};
ctx.strokeStyle = '#3B82F6';
ctx.lineWidth = 4 / scale;
ctx.globalAlpha = 0.25 * (Math.sin(frameCount * 0.0125) ** 2);
ctx.strokeRect(x, y, width, height);
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
ctx.strokeStyle = 'black';
if (SHOW_SELECTOR_TEXT) {
ctx.font = `${16 / scale}px Verdana`;
ctx.textAlign = 'center';
ctx.fillText(props.selected.properties.displayedText, xText, yText);
ctx.textAlign = 'left';
}
}

View file

@ -9,12 +9,17 @@ import { SaveEditorAsJSON, SaveEditorAsSVG } from './Actions/Save';
import { OnKey } from './Actions/Shortcuts'; import { OnKey } from './Actions/Shortcuts';
import { events as EVENTS } from '../../Events/EditorEvents'; import { events as EVENTS } from '../../Events/EditorEvents';
import { IEditorState } from '../../Interfaces/IEditorState'; import { IEditorState } from '../../Interfaces/IEditorState';
import { DISABLE_API, MAX_HISTORY } from '../../utils/default'; import { DIMENSION_MARGIN, DISABLE_API, MAX_HISTORY, USE_EXPERIMENTAL_CANVAS_API } from '../../utils/default';
import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './Actions/SymbolOperations'; import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './Actions/SymbolOperations';
import { FindContainerById } from '../../utils/itertools'; import { FindContainerById, MakeRecursionDFSIterator } from '../../utils/itertools';
import { IMenuAction, Menu } from '../Menu/Menu'; import { IMenuAction, Menu } from '../Menu/Menu';
import { GetAction } from './Actions/ContextMenuActions'; import { GetAction } from './Actions/ContextMenuActions';
import { AddContainerToSelectedContainer, AddContainer } from './Actions/AddContainer'; import { AddContainerToSelectedContainer, AddContainer } from './Actions/AddContainer';
import { Canvas } from '../Canvas/Canvas';
import { BAR_WIDTH } from '../Bar/Bar';
import { IPoint } from '../../Interfaces/IPoint';
import { AddDimensions } from '../Canvas/DimensionLayer';
import { RenderSelector } from '../Canvas/Selector';
interface IEditorProps { interface IEditorProps {
root: Element | Document root: Element | Document
@ -228,6 +233,55 @@ export function Editor(props: IEditorProps): JSX.Element {
const configuration = props.configuration; const configuration = props.configuration;
const current = GetCurrentHistoryState(history, historyCurrentStep); const current = GetCurrentHistoryState(history, historyCurrentStep);
const selected = FindContainerById(current.mainContainer, current.selectedContainerId); const selected = FindContainerById(current.mainContainer, current.selectedContainerId);
function Draw(ctx: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint): void {
const topDim = current.mainContainer.properties.y;
const leftDim = current.mainContainer.properties.x;
const rightDim = current.mainContainer.properties.x + current.mainContainer.properties.width;
const bottomDim = current.mainContainer.properties.y + current.mainContainer.properties.height;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.save();
ctx.translate(translatePos.x, translatePos.y);
ctx.scale(scale, scale);
ctx.fillStyle = '#000000';
const it = MakeRecursionDFSIterator(current.mainContainer, 0, [0, 0]);
for (const { container, depth, currentTransform } of it) {
const [x, y] = [
container.properties.x + currentTransform[0],
container.properties.y + currentTransform[1]
];
// Draw container
ctx.strokeStyle = container.properties.style?.stroke ?? '#000000';
ctx.fillStyle = container.properties.style?.fill ?? '#000000';
ctx.lineWidth = Number(container.properties.style?.strokeWidth ?? 1);
ctx.globalAlpha = Number(container.properties.style?.fillOpacity ?? 1);
ctx.fillRect(x, y, container.properties.width, container.properties.height);
ctx.globalAlpha = Number(container.properties.style?.strokeOpacity ?? 1);
ctx.strokeRect(x, y, container.properties.width, container.properties.height);
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
ctx.fillStyle = '#000000';
ctx.strokeStyle = '#000000';
// Draw dimensions
const containerLeftDim = leftDim - (DIMENSION_MARGIN * (depth + 1)) / scale;
const containerTopDim = topDim - (DIMENSION_MARGIN * (depth + 1)) / scale;
const containerBottomDim = bottomDim + (DIMENSION_MARGIN * (depth + 1)) / scale;
const containerRightDim = rightDim + (DIMENSION_MARGIN * (depth + 1)) / scale;
const dimMapped = [containerLeftDim, containerBottomDim, containerTopDim, containerRightDim];
AddDimensions(ctx, container, dimMapped, currentTransform, scale, depth);
// Draw selector
RenderSelector(ctx, frameCount, {
scale,
selected
});
}
ctx.restore();
}
return ( return (
<div ref={editorRef} className="Editor font-sans h-full"> <div ref={editorRef} className="Editor font-sans h-full">
<UI <UI
@ -310,15 +364,26 @@ export function Editor(props: IEditorProps): JSX.Element {
configuration configuration
)} )}
saveEditorAsSVG={() => SaveEditorAsSVG()} saveEditorAsSVG={() => SaveEditorAsSVG()}
loadState={(move) => setHistoryCurrentStep(move)} /> loadState={(move) => setHistoryCurrentStep(move)}
<SVG />
width={current.mainContainer?.properties.width} {
height={current.mainContainer?.properties.height} USE_EXPERIMENTAL_CANVAS_API
selected={selected} ? <Canvas
symbols={current.symbols} draw={Draw}
> className='ml-16'
{current.mainContainer} width={window.innerWidth - BAR_WIDTH}
</SVG> height={window.innerHeight}
/>
: <SVG
width={current.mainContainer?.properties.width}
height={current.mainContainer?.properties.height}
selected={selected}
symbols={current.symbols}
>
{current.mainContainer}
</SVG>
}
<Menu <Menu
getListener={() => editorRef.current} getListener={() => editorRef.current}
actions={menuActions} actions={menuActions}

View file

@ -17,6 +17,12 @@ export const FAST_BOOT = false;
/** Disable any call to the API (default = false) */ /** Disable any call to the API (default = false) */
export const DISABLE_API = false; export const DISABLE_API = false;
/**
* Replace the SVG viewer by a canvas
* EXPERIMENTAL: svg export wont work and it won't be possible to insert a custom svg)
*/
export const USE_EXPERIMENTAL_CANVAS_API = false;
/** Enable keyboard shortcuts (default = true) */ /** Enable keyboard shortcuts (default = true) */
export const ENABLE_SHORTCUTS = true; export const ENABLE_SHORTCUTS = true;