Save state and elements automatically

This commit is contained in:
Paulo Menezes 2020-01-03 13:28:16 -03:00
commit c59d3c90f4
4 changed files with 853 additions and 713 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
package-lock.json
# Dependency directories # Dependency directories
node_modules/ node_modules/

View file

@ -14,6 +14,7 @@ type ExcaliburTextElement = ExcaliburElement & {
}; };
const LOCAL_STORAGE_KEY = "excalibur"; const LOCAL_STORAGE_KEY = "excalibur";
const LOCAL_STORAGE_KEY_STATE = "excalibur-state";
var elements = Array.of<ExcaliburElement>(); var elements = Array.of<ExcaliburElement>();
@ -107,7 +108,15 @@ function hitTest(element: ExcaliburElement, x: number, y: number): boolean {
} }
} }
function newElement(type: string, x: number, y: number, width = 0, height = 0) { function newElement(
type: string,
x: number,
y: number,
strokeColor: string,
backgroundColor: string,
width = 0,
height = 0
) {
const element = { const element = {
type: type, type: type,
x: x, x: x,
@ -115,29 +124,69 @@ function newElement(type: string, x: number, y: number, width = 0, height = 0) {
width: width, width: width,
height: height, height: height,
isSelected: false, isSelected: false,
strokeColor: strokeColor,
backgroundColor: backgroundColor,
draw(rc: RoughCanvas, context: CanvasRenderingContext2D) {} draw(rc: RoughCanvas, context: CanvasRenderingContext2D) {}
}; };
return element; return element;
} }
function renderScene(
rc: RoughCanvas,
context: CanvasRenderingContext2D,
// null indicates transparent bg
viewBackgroundColor: string | null
) {
if (!context) return;
const fillStyle = context.fillStyle;
if (typeof viewBackgroundColor === "string") {
context.fillStyle = viewBackgroundColor;
context.fillRect(-0.5, -0.5, canvas.width, canvas.height);
} else {
context.clearRect(-0.5, -0.5, canvas.width, canvas.height);
}
context.fillStyle = fillStyle;
elements.forEach(element => {
element.draw(rc, context);
if (element.isSelected) {
const margin = 4;
const elementX1 = getElementAbsoluteX1(element);
const elementX2 = getElementAbsoluteX2(element);
const elementY1 = getElementAbsoluteY1(element);
const elementY2 = getElementAbsoluteY2(element);
const lineDash = context.getLineDash();
context.setLineDash([8, 4]);
context.strokeRect(
elementX1 - margin,
elementY1 - margin,
elementX2 - elementX1 + margin * 2,
elementY2 - elementY1 + margin * 2
);
context.setLineDash(lineDash);
}
});
}
function exportAsPNG({ function exportAsPNG({
exportBackground, exportBackground,
exportVisibleOnly, exportVisibleOnly,
exportPadding = 10, exportPadding = 10,
viewBgColor viewBackgroundColor
}: { }: {
exportBackground: boolean; exportBackground: boolean;
exportVisibleOnly: boolean; exportVisibleOnly: boolean;
exportPadding?: number; exportPadding?: number;
viewBgColor: string; viewBackgroundColor: string;
}) { }) {
if (!elements.length) return window.alert("Cannot export empty canvas."); if (!elements.length) return window.alert("Cannot export empty canvas.");
// deselect & rerender // deselect & rerender
clearSelection(); clearSelection();
drawScene(); ReactDOM.render(<App />, rootElement, () => {
// calculate visible-area coords // calculate visible-area coords
let subCanvasX1 = Infinity; let subCanvasX1 = Infinity;
@ -165,9 +214,10 @@ function exportAsPNG({
? subCanvasY2 - subCanvasY1 + exportPadding * 2 ? subCanvasY2 - subCanvasY1 + exportPadding * 2
: canvas.height; : canvas.height;
if (exportBackground) { // if we're exporting without bg, we need to rerender the scene without it
tempCanvasCtx.fillStyle = viewBgColor; // (it's reset again, below)
tempCanvasCtx.fillRect(0, 0, canvas.width, canvas.height); if (!exportBackground) {
renderScene(rc, context, null);
} }
// copy our original canvas onto the temp canvas // copy our original canvas onto the temp canvas
@ -191,6 +241,11 @@ function exportAsPNG({
exportVisibleOnly ? tempCanvas.height : canvas.height // dHeight exportVisibleOnly ? tempCanvas.height : canvas.height // dHeight
); );
// reset transparent bg back to original
if (!exportBackground) {
renderScene(rc, context, viewBackgroundColor);
}
// create a temporary <a> elem which we'll use to download the image // create a temporary <a> elem which we'll use to download the image
const link = document.createElement("a"); const link = document.createElement("a");
link.setAttribute("download", "excalibur.png"); link.setAttribute("download", "excalibur.png");
@ -200,6 +255,7 @@ function exportAsPNG({
// clean up the DOM // clean up the DOM
link.remove(); link.remove();
if (tempCanvas !== canvas) tempCanvas.remove(); if (tempCanvas !== canvas) tempCanvas.remove();
});
} }
function rotate(x1: number, y1: number, x2: number, y2: number, angle: number) { function rotate(x1: number, y1: number, x2: number, y2: number, angle: number) {
@ -242,11 +298,7 @@ function getArrowPoints(element: ExcaliburElement) {
return [x1, y1, x2, y2, x3, y3, x4, y4]; return [x1, y1, x2, y2, x3, y3, x4, y4];
} }
function generateDraw( function generateDraw(element: ExcaliburElement) {
element: ExcaliburElement,
itemStrokeColor: string,
itemBackgroundColorColor: string
) {
if (element.type === "selection") { if (element.type === "selection") {
element.draw = (rc, context) => { element.draw = (rc, context) => {
const fillStyle = context.fillStyle; const fillStyle = context.fillStyle;
@ -256,8 +308,8 @@ function generateDraw(
}; };
} else if (element.type === "rectangle") { } else if (element.type === "rectangle") {
const shape = generator.rectangle(0, 0, element.width, element.height, { const shape = generator.rectangle(0, 0, element.width, element.height, {
stroke: itemStrokeColor, stroke: element.strokeColor,
fill: itemBackgroundColorColor fill: element.backgroundColor
}); });
element.draw = (rc, context) => { element.draw = (rc, context) => {
context.translate(element.x, element.y); context.translate(element.x, element.y);
@ -270,7 +322,7 @@ function generateDraw(
element.height / 2, element.height / 2,
element.width, element.width,
element.height, element.height,
{ stroke: itemStrokeColor, fill: itemBackgroundColorColor } { stroke: element.strokeColor, fill: element.backgroundColor }
); );
element.draw = (rc, context) => { element.draw = (rc, context) => {
context.translate(element.x, element.y); context.translate(element.x, element.y);
@ -281,11 +333,11 @@ function generateDraw(
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
const shapes = [ const shapes = [
// \ // \
generator.line(x3, y3, x2, y2, { stroke: itemStrokeColor }), generator.line(x3, y3, x2, y2, { stroke: element.strokeColor }),
// ----- // -----
generator.line(x1, y1, x2, y2, { stroke: itemStrokeColor }), generator.line(x1, y1, x2, y2, { stroke: element.strokeColor }),
// / // /
generator.line(x4, y4, x2, y2, { stroke: itemStrokeColor }) generator.line(x4, y4, x2, y2, { stroke: element.strokeColor })
]; ];
element.draw = (rc, context) => { element.draw = (rc, context) => {
@ -360,41 +412,26 @@ function deleteSelectedElements() {
} }
} }
function save() { function save(state: AppState) {
if (elements && elements.length > 0) { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
const items = [...elements]; localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state));
for (const item of items) {
item.isSelected = false;
}
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(items));
}
window.removeEventListener("beforeunload", onUnload);
} }
function restore() { function restore() {
const el = localStorage.getItem(LOCAL_STORAGE_KEY); try {
const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);
const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);
if (el) { if (savedElements) {
const items = JSON.parse(el); elements = JSON.parse(savedElements);
for (let item of items) { elements.forEach((element: ExcaliburElement) => generateDraw(element));
item = generateDraw(item, "#000000", "#ffffff");
}
elements = [...items];
}
} }
function onUnload(event: BeforeUnloadEvent) { return savedState ? JSON.parse(savedState) : null;
event.preventDefault(); } catch (e) {
elements = [];
const confirmationMessage = "excalibur"; return null;
event.returnValue = confirmationMessage;
return confirmationMessage;
} }
function addOnBeforeUnload() {
window.addEventListener("beforeunload", onUnload);
} }
type AppState = { type AppState = {
@ -403,19 +440,45 @@ type AppState = {
exportBackground: boolean; exportBackground: boolean;
exportVisibleOnly: boolean; exportVisibleOnly: boolean;
exportPadding: number; exportPadding: number;
itemStrokeColor: string; currentItemStrokeColor: string;
itemBackgroundColor: string; currentItemBackgroundColor: string;
viewBgColor: string; viewBackgroundColor: string;
}; };
const KEYS = {
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp",
ESCAPE: "Escape",
DELETE: "Delete",
BACKSPACE: "Backspace"
};
function isArrowKey(keyCode: string) {
return (
keyCode === KEYS.ARROW_LEFT ||
keyCode === KEYS.ARROW_RIGHT ||
keyCode === KEYS.ARROW_DOWN ||
keyCode === KEYS.ARROW_UP
);
}
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
const ELEMENT_TRANSLATE_AMOUNT = 1;
class App extends React.Component<{}, AppState> { class App extends React.Component<{}, AppState> {
public componentDidMount() { public componentDidMount() {
document.addEventListener("keydown", this.onKeyDown, false); document.addEventListener("keydown", this.onKeyDown, false);
const savedState = restore();
if (savedState) {
this.setState(savedState);
}
} }
public componentWillUnmount() { public componentWillUnmount() {
document.removeEventListener("keydown", this.onKeyDown, false); document.removeEventListener("keydown", this.onKeyDown, false);
window.removeEventListener("beforeunload", onUnload);
} }
public state: AppState = { public state: AppState = {
@ -424,9 +487,9 @@ class App extends React.Component<{}, AppState> {
exportBackground: false, exportBackground: false,
exportVisibleOnly: true, exportVisibleOnly: true,
exportPadding: 10, exportPadding: 10,
itemStrokeColor: "#000000", currentItemStrokeColor: "#000000",
itemBackgroundColor: "#ffffff", currentItemBackgroundColor: "#ffffff",
viewBgColor: "#ffffff" viewBackgroundColor: "#ffffff"
}; };
private onKeyDown = (event: KeyboardEvent) => { private onKeyDown = (event: KeyboardEvent) => {
@ -434,39 +497,33 @@ class App extends React.Component<{}, AppState> {
return; return;
} }
if (event.key === "Escape") { if (event.key === KEYS.ESCAPE) {
clearSelection(); clearSelection();
drawScene(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === "Backspace") { } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
addOnBeforeUnload();
deleteSelectedElements(); deleteSelectedElements();
drawScene(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
} else if ( } else if (isArrowKey(event.key)) {
event.key === "ArrowLeft" || const step = event.shiftKey
event.key === "ArrowRight" || ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
event.key === "ArrowUp" || : ELEMENT_TRANSLATE_AMOUNT;
event.key === "ArrowDown"
) {
addOnBeforeUnload();
const step = event.shiftKey ? 5 : 1;
elements.forEach(element => { elements.forEach(element => {
if (element.isSelected) { if (element.isSelected) {
if (event.key === "ArrowLeft") element.x -= step; if (event.key === KEYS.ARROW_LEFT) element.x -= step;
else if (event.key === "ArrowRight") element.x += step; else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
else if (event.key === "ArrowUp") element.y -= step; else if (event.key === KEYS.ARROW_UP) element.y -= step;
else if (event.key === "ArrowDown") element.y += step; else if (event.key === KEYS.ARROW_DOWN) element.y += step;
} }
}); });
drawScene(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === "a" && event.metaKey) { } else if (event.key === "a" && event.metaKey) {
elements.forEach(element => { elements.forEach(element => {
element.isSelected = true; element.isSelected = true;
}); });
drawScene(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
} }
}; };
@ -486,7 +543,7 @@ class App extends React.Component<{}, AppState> {
onChange={() => { onChange={() => {
this.setState({ elementType: type }); this.setState({ elementType: type });
clearSelection(); clearSelection();
drawScene(); this.forceUpdate();
}} }}
/> />
{children} {children}
@ -496,66 +553,6 @@ class App extends React.Component<{}, AppState> {
public render() { public render() {
return ( return (
<>
<div className="wrappers">
<div className="saveWrapper">
<button disabled={elements.length === 0} onClick={save}>
Save
</button>
</div>
<div className="exportWrapper">
<button
onClick={() => {
exportAsPNG({
exportBackground: this.state.exportBackground,
exportVisibleOnly: this.state.exportVisibleOnly,
exportPadding: this.state.exportPadding,
viewBgColor: this.state.viewBgColor
});
}}
>
Export to png
</button>
<label>
<input
type="checkbox"
checked={this.state.exportBackground}
onChange={e => {
this.setState({ exportBackground: e.target.checked });
}}
/>{" "}
background
</label>
<label>
<input
type="checkbox"
checked={this.state.exportVisibleOnly}
onChange={e => {
this.setState({ exportVisibleOnly: e.target.checked });
}}
/>
visible area only
</label>
(padding:
<input
type="number"
value={this.state.exportPadding}
onChange={e => {
this.setState({ exportPadding: Number(e.target.value) });
}}
disabled={!this.state.exportVisibleOnly}
/>
px)
</div>
</div>
<fieldset>
<legend>Shapes</legend>
{this.renderOption({ type: "rectangle", children: "Rectangle" })}
{this.renderOption({ type: "ellipse", children: "Ellipse" })}
{this.renderOption({ type: "arrow", children: "Arrow" })}
{this.renderOption({ type: "text", children: "Text" })}
{this.renderOption({ type: "selection", children: "Selection" })}
</fieldset>
<div <div
onCut={e => { onCut={e => {
e.clipboardData.setData( e.clipboardData.setData(
@ -563,7 +560,7 @@ class App extends React.Component<{}, AppState> {
JSON.stringify(elements.filter(element => element.isSelected)) JSON.stringify(elements.filter(element => element.isSelected))
); );
deleteSelectedElements(); deleteSelectedElements();
drawScene(); this.forceUpdate();
e.preventDefault(); e.preventDefault();
}} }}
onCopy={e => { onCopy={e => {
@ -588,19 +585,23 @@ class App extends React.Component<{}, AppState> {
parsedElements.forEach(parsedElement => { parsedElements.forEach(parsedElement => {
parsedElement.x += 10; parsedElement.x += 10;
parsedElement.y += 10; parsedElement.y += 10;
generateDraw( generateDraw(parsedElement);
parsedElement,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
elements.push(parsedElement); elements.push(parsedElement);
addOnBeforeUnload();
}); });
drawScene(); this.forceUpdate();
} }
e.preventDefault(); e.preventDefault();
}} }}
> >
<fieldset>
<legend>Shapes</legend>
{this.renderOption({ type: "rectangle", children: "Rectangle" })}
{this.renderOption({ type: "ellipse", children: "Ellipse" })}
{this.renderOption({ type: "arrow", children: "Arrow" })}
{this.renderOption({ type: "text", children: "Text" })}
{this.renderOption({ type: "selection", children: "Selection" })}
</fieldset>
<canvas <canvas
id="canvas" id="canvas"
width={window.innerWidth} width={window.innerWidth}
@ -608,7 +609,13 @@ class App extends React.Component<{}, AppState> {
onMouseDown={e => { onMouseDown={e => {
const x = e.clientX - (e.target as HTMLElement).offsetLeft; const x = e.clientX - (e.target as HTMLElement).offsetLeft;
const y = e.clientY - (e.target as HTMLElement).offsetTop; const y = e.clientY - (e.target as HTMLElement).offsetTop;
const element = newElement(this.state.elementType, x, y); const element = newElement(
this.state.elementType,
x,
y,
this.state.currentItemStrokeColor,
this.state.currentItemBackgroundColor
);
let isDraggingElements = false; let isDraggingElements = false;
const cursorStyle = document.documentElement.style.cursor; const cursorStyle = document.documentElement.style.cursor;
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
@ -634,15 +641,11 @@ class App extends React.Component<{}, AppState> {
clearSelection(); clearSelection();
} }
isDraggingElements = elements.some( isDraggingElements = elements.some(element => element.isSelected);
element => element.isSelected
);
if (isDraggingElements) { if (isDraggingElements) {
document.documentElement.style.cursor = "move"; document.documentElement.style.cursor = "move";
} }
} else {
addOnBeforeUnload();
} }
if (isTextElement(element)) { if (isTextElement(element)) {
@ -661,8 +664,7 @@ class App extends React.Component<{}, AppState> {
} = context.measureText(element.text); } = context.measureText(element.text);
element.actualBoundingBoxAscent = actualBoundingBoxAscent; element.actualBoundingBoxAscent = actualBoundingBoxAscent;
context.font = font; context.font = font;
const height = const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
actualBoundingBoxAscent + actualBoundingBoxDescent;
// Center the text // Center the text
element.x -= width / 2; element.x -= width / 2;
element.y -= actualBoundingBoxAscent; element.y -= actualBoundingBoxAscent;
@ -670,11 +672,7 @@ class App extends React.Component<{}, AppState> {
element.height = height; element.height = height;
} }
generateDraw( generateDraw(element);
element,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
elements.push(element); elements.push(element);
if (this.state.elementType === "text") { if (this.state.elementType === "text") {
this.setState({ this.setState({
@ -706,8 +704,7 @@ class App extends React.Component<{}, AppState> {
}); });
lastX = x; lastX = x;
lastY = y; lastY = y;
drawScene(); this.forceUpdate();
addOnBeforeUnload();
return; return;
} }
} }
@ -722,16 +719,12 @@ class App extends React.Component<{}, AppState> {
// Make a perfect square or circle when shift is enabled // Make a perfect square or circle when shift is enabled
draggingElement.height = e.shiftKey ? width : height; draggingElement.height = e.shiftKey ? width : height;
generateDraw( generateDraw(draggingElement);
draggingElement,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
setSelection(draggingElement); setSelection(draggingElement);
} }
drawScene(); this.forceUpdate();
}; };
const onMouseUp = (e: MouseEvent) => { const onMouseUp = (e: MouseEvent) => {
@ -745,7 +738,7 @@ class App extends React.Component<{}, AppState> {
// if no element is clicked, clear the selection and redraw // if no element is clicked, clear the selection and redraw
if (draggingElement === null) { if (draggingElement === null) {
clearSelection(); clearSelection();
drawScene(); this.forceUpdate();
return; return;
} }
@ -762,24 +755,23 @@ class App extends React.Component<{}, AppState> {
draggingElement: null, draggingElement: null,
elementType: "selection" elementType: "selection"
}); });
drawScene(); this.forceUpdate();
}; };
window.addEventListener("mousemove", onMouseMove); window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp); window.addEventListener("mouseup", onMouseUp);
drawScene(); this.forceUpdate();
}} }}
/> />
</div>
<fieldset> <fieldset>
<legend>Colors</legend> <legend>Colors</legend>
<label> <label>
<input <input
type="color" type="color"
value={this.state.viewBgColor} value={this.state.viewBackgroundColor}
onChange={e => { onChange={e => {
this.setState({ viewBgColor: e.target.value }); this.setState({ viewBackgroundColor: e.target.value });
}} }}
/> />
Background Background
@ -787,9 +779,9 @@ class App extends React.Component<{}, AppState> {
<label> <label>
<input <input
type="color" type="color"
value={this.state.itemStrokeColor} value={this.state.currentItemStrokeColor}
onChange={e => { onChange={e => {
this.setState({ itemStrokeColor: e.target.value }); this.setState({ currentItemStrokeColor: e.target.value });
}} }}
/> />
Shape Stroke Shape Stroke
@ -797,9 +789,9 @@ class App extends React.Component<{}, AppState> {
<label> <label>
<input <input
type="color" type="color"
value={this.state.itemBackgroundColor} value={this.state.currentItemBackgroundColor}
onChange={e => { onChange={e => {
this.setState({ itemBackgroundColor: e.target.value }); this.setState({ currentItemBackgroundColor: e.target.value });
}} }}
/> />
Shape Background Shape Background
@ -813,7 +805,7 @@ class App extends React.Component<{}, AppState> {
exportBackground: this.state.exportBackground, exportBackground: this.state.exportBackground,
exportVisibleOnly: this.state.exportVisibleOnly, exportVisibleOnly: this.state.exportVisibleOnly,
exportPadding: this.state.exportPadding, exportPadding: this.state.exportPadding,
viewBgColor: this.state.viewBgColor viewBackgroundColor: this.state.viewBackgroundColor
}); });
}} }}
> >
@ -850,35 +842,13 @@ class App extends React.Component<{}, AppState> {
/> />
px) px)
</fieldset> </fieldset>
</> </div>
); );
} }
componentDidUpdate() { componentDidUpdate() {
const fillStyle = context.fillStyle; renderScene(rc, context, this.state.viewBackgroundColor);
context.fillStyle = this.state.viewBgColor; save(this.state);
context.fillRect(-0.5, -0.5, canvas.width, canvas.height);
context.fillStyle = fillStyle;
elements.forEach(element => {
element.draw(rc, context);
if (element.isSelected) {
const margin = 4;
const elementX1 = getElementAbsoluteX1(element);
const elementX2 = getElementAbsoluteX2(element);
const elementY1 = getElementAbsoluteY1(element);
const elementY2 = getElementAbsoluteY2(element);
const lineDash = context.getLineDash();
context.setLineDash([8, 4]);
context.strokeRect(
elementX1 - margin,
elementY1 - margin,
elementX2 - elementX1 + margin * 2,
elementY2 - elementY1 + margin * 2
);
context.setLineDash(lineDash);
}
});
} }
} }
@ -892,10 +862,4 @@ const context = canvas.getContext("2d")!;
// https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402 // https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402
context.translate(0.5, 0.5); context.translate(0.5, 0.5);
restore();
function drawScene() {
ReactDOM.render(<App />, rootElement); ReactDOM.render(<App />, rootElement);
}
drawScene();

View file

@ -4,32 +4,24 @@
src: url("https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf"); src: url("https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf");
} }
.wrappers { body {
display: flex; margin: 0;
align-items: center;
margin-bottom: 10px;
} }
.exportWrapper { /* Controls - Begin */
display: flex; fieldset {
align-items: center; margin: 5px;
}
.exportWrapper label {
display: flex;
align-items: center;
margin: 0 5px;
}
.exportWrapper button {
margin-right: 10px;
} }
label { label {
margin-right: 10px; margin-right: 10px;
} }
input[type="number"] { input[type="number"] {
width: 30px; width: 30px;
} }
input { input {
margin-right: 5px; margin-right: 5px;
} }
/* Controls - End */

735
yarn.lock

File diff suppressed because it is too large Load diff