Explorar el Código

Redesign idea (#343)

* Redisign idea

* Code cleanup

* Fixed to right container

* Reoredered layout

* Reordering panels

* Export dialog

* Removed redunant code

* Fixed not removing temp canvas

* Fixed preview not using only selected elements

* Returned file name on export

* Toggle export selected/all elements

* Hide copy to clipboard button if no support of clipboard

* Added border to swatches

* Fixed modal flickering
Timur Khazamov hace 5 años
padre
commit
79aee53ff6

+ 19 - 16
src/actions/actionCanvas.tsx

@@ -2,43 +2,46 @@ import React from "react";
 import { Action } from "./types";
 import { ColorPicker } from "../components/ColorPicker";
 import { getDefaultAppState } from "../appState";
+import { trash } from "../components/icons";
+import { ToolIcon } from "../components/ToolIcon";
 
 export const actionChangeViewBackgroundColor: Action = {
   name: "changeViewBackgroundColor",
   perform: (elements, appState, value) => {
     return { appState: { ...appState, viewBackgroundColor: value } };
   },
-  PanelComponent: ({ appState, updateData }) => (
-    <>
-      <h5>Canvas Background Color</h5>
-      <ColorPicker
-        type="canvasBackground"
-        color={appState.viewBackgroundColor}
-        onChange={color => updateData(color)}
-      />
-    </>
-  )
+  PanelComponent: ({ appState, updateData }) => {
+    return (
+      <div style={{ position: "relative" }}>
+        <ColorPicker
+          type="canvasBackground"
+          color={appState.viewBackgroundColor}
+          onChange={color => updateData(color)}
+        />
+      </div>
+    );
+  }
 };
 
 export const actionClearCanvas: Action = {
   name: "clearCanvas",
-  perform: (elements, appState, value) => {
+  perform: () => {
     return {
       elements: [],
       appState: getDefaultAppState()
     };
   },
   PanelComponent: ({ updateData }) => (
-    <button
+    <ToolIcon
       type="button"
+      icon={trash}
+      title="Clear the canvas & reset background color"
+      aria-label="Clear the canvas & reset background color"
       onClick={() => {
         if (window.confirm("This will clear the whole canvas. Are you sure?")) {
           updateData(null);
         }
       }}
-      title="Clear the canvas & reset background color"
-    >
-      Clear canvas
-    </button>
+    />
   )
 };

+ 21 - 16
src/actions/actionExport.tsx

@@ -2,6 +2,8 @@ import React from "react";
 import { Action } from "./types";
 import { EditableText } from "../components/EditableText";
 import { saveAsJSON, loadFromJSON } from "../scene";
+import { load, save } from "../components/icons";
+import { ToolIcon } from "../components/ToolIcon";
 
 export const actionChangeProjectName: Action = {
   name: "changeProjectName",
@@ -9,15 +11,10 @@ export const actionChangeProjectName: Action = {
     return { appState: { ...appState, name: value } };
   },
   PanelComponent: ({ appState, updateData }) => (
-    <>
-      <h5>Name</h5>
-      {appState.name && (
-        <EditableText
-          value={appState.name}
-          onChange={(name: string) => updateData(name)}
-        />
-      )}
-    </>
+    <EditableText
+      value={appState.name || "Unnamed"}
+      onChange={(name: string) => updateData(name)}
+    />
   )
 };
 
@@ -34,8 +31,8 @@ export const actionChangeExportBackground: Action = {
         onChange={e => {
           updateData(e.target.checked);
         }}
-      />
-      background
+      />{" "}
+      With background
     </label>
   )
 };
@@ -47,7 +44,13 @@ export const actionSaveScene: Action = {
     return {};
   },
   PanelComponent: ({ updateData }) => (
-    <button onClick={() => updateData(null)}>Save as...</button>
+    <ToolIcon
+      type="button"
+      icon={save}
+      title="Save"
+      aria-label="Save"
+      onClick={() => updateData(null)}
+    />
   )
 };
 
@@ -57,14 +60,16 @@ export const actionLoadScene: Action = {
     return { elements: loadedElements };
   },
   PanelComponent: ({ updateData }) => (
-    <button
+    <ToolIcon
+      type="button"
+      icon={load}
+      title="Load"
+      aria-label="Load"
       onClick={() => {
         loadFromJSON().then(({ elements }) => {
           updateData(elements);
         });
       }}
-    >
-      Load file...
-    </button>
+    />
   )
 };

+ 20 - 23
src/actions/actionProperties.tsx

@@ -3,8 +3,8 @@ import { Action } from "./types";
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 import { getSelectedAttribute } from "../scene";
 import { ButtonSelect } from "../components/ButtonSelect";
-import { PanelColor } from "../components/panels/PanelColor";
 import { isTextElement, redrawTextBoundingBox } from "../element";
+import { ColorPicker } from "../components/ColorPicker";
 
 const changeProperty = (
   elements: readonly ExcalidrawElement[],
@@ -31,17 +31,14 @@ export const actionChangeStrokeColor: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData }) => (
-    <PanelColor
-      title="Stroke Color"
-      colorType="elementStroke"
-      onColorChange={(color: string) => {
-        updateData(color);
-      }}
-      colorValue={getSelectedAttribute(
-        elements,
-        element => element.strokeColor
-      )}
-    />
+    <>
+      <h5>Stroke</h5>
+      <ColorPicker
+        type="elementStroke"
+        color={getSelectedAttribute(elements, element => element.strokeColor)}
+        onChange={updateData}
+      />
+    </>
   )
 };
 
@@ -58,17 +55,17 @@ export const actionChangeBackgroundColor: Action = {
     };
   },
   PanelComponent: ({ elements, updateData }) => (
-    <PanelColor
-      title="Background Color"
-      colorType="elementBackground"
-      onColorChange={(color: string) => {
-        updateData(color);
-      }}
-      colorValue={getSelectedAttribute(
-        elements,
-        element => element.backgroundColor
-      )}
-    />
+    <>
+      <h5>Background</h5>
+      <ColorPicker
+        type="elementBackground"
+        color={getSelectedAttribute(
+          elements,
+          element => element.backgroundColor
+        )}
+        onChange={updateData}
+      />
+    </>
   )
 };
 

+ 1 - 1
src/actions/types.ts

@@ -52,7 +52,7 @@ export interface ActionsManagerInterface {
   ) => { label: string; action: () => void }[];
   renderAction: (
     name: string,
-    elements: ExcalidrawElement[],
+    elements: readonly ExcalidrawElement[],
     appState: AppState,
     updater: UpdaterFn
   ) => React.ReactElement | null;

+ 18 - 0
src/components/ColorPicker.css

@@ -42,6 +42,8 @@
   float: left;
   border-radius: 4px;
   margin: 0px 6px 6px 0px;
+  box-sizing: border-box;
+  border: 1px solid #ddd;
 }
 
 .color-picker-swatch:focus {
@@ -87,3 +89,19 @@
   float: left;
   padding-left: 8px;
 }
+
+.color-picker-label-swatch {
+  height: 24px;
+  width: 24px;
+  display: inline-block;
+  margin-right: 4px;
+}
+
+.color-picker-swatch-input {
+  font-size: 16px;
+  display: inline-block;
+  width: 100px;
+  border-radius: 2px;
+  padding: 2px 4px;
+  border: 1px solid #ddd;
+}

+ 2 - 2
src/components/ColorPicker.tsx

@@ -75,7 +75,7 @@ export function ColorPicker({
   return (
     <div>
       <button
-        className="swatch"
+        className="color-picker-label-swatch"
         style={color ? { backgroundColor: color } : undefined}
         onClick={() => setActive(!isActive)}
       />
@@ -94,7 +94,7 @@ export function ColorPicker({
       </React.Suspense>
       <input
         type="text"
-        className="swatch-input"
+        className="color-picker-swatch-input"
         value={color || ""}
         onPaste={e => onChange(e.clipboardData.getData("text"))}
         onChange={e => onChange(e.target.value)}

+ 18 - 0
src/components/EditableText.css

@@ -0,0 +1,18 @@
+.project-name {
+  display: inline-block;
+  cursor: pointer;
+  border: none;
+  padding: 4px;
+  margin: -4px;
+  white-space: nowrap;
+  border-radius: var(--space-factor);
+}
+
+.project-name:hover {
+  background-color: #eee;
+}
+
+.project-name:focus {
+  outline: none;
+  box-shadow: 0 0 0 2px steelblue;
+}

+ 30 - 59
src/components/EditableText.tsx

@@ -1,73 +1,44 @@
-import React, { Fragment, Component } from "react";
+import "./EditableText.css";
 
-type InputState = {
-  value: string;
-  edit: boolean;
-};
+import React, { Component } from "react";
+import { selectNode, removeSelection } from "../utils";
 
 type Props = {
   value: string;
   onChange: (value: string) => void;
 };
 
-export class EditableText extends Component<Props, InputState> {
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      value: props.value,
-      edit: false
-    };
-  }
-
-  UNSAFE_componentWillReceiveProps(props: Props) {
-    this.setState({ value: props.value });
-  }
-
-  private handleEdit(e: React.ChangeEvent<HTMLInputElement>) {
-    this.setState({ value: e.target.value });
-  }
-
-  private handleBlur() {
-    const { value } = this.state;
-
-    if (!value) {
-      this.setState({ value: this.props.value, edit: false });
-      return;
+export class EditableText extends Component<Props> {
+  private handleFocus = (e: React.FocusEvent<HTMLElement>) => {
+    selectNode(e.currentTarget);
+  };
+
+  private handleBlur = (e: React.FocusEvent<HTMLElement>) => {
+    const value = e.currentTarget.innerText.trim();
+    if (value !== this.props.value) this.props.onChange(value);
+    removeSelection();
+  };
+
+  private handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
+    if (e.key === "Enter") {
+      e.preventDefault();
+      e.currentTarget.blur();
     }
-    this.props.onChange(value);
-    this.setState({ edit: false });
-  }
+  };
 
   public render() {
-    const { value, edit } = this.state;
-
     return (
-      <Fragment>
-        {edit ? (
-          <input
-            className="project-name-input"
-            name="name"
-            maxLength={25}
-            value={value}
-            onChange={e => this.handleEdit(e)}
-            onBlur={() => this.handleBlur()}
-            onKeyDown={e => {
-              if (e.key === "Enter") {
-                this.handleBlur();
-              }
-            }}
-            autoFocus
-          />
-        ) : (
-          <span
-            onClick={() => this.setState({ edit: true })}
-            className="project-name"
-          >
-            {value}
-          </span>
-        )}
-      </Fragment>
+      <span
+        suppressContentEditableWarning
+        contentEditable="true"
+        data-type="wysiwyg"
+        className="project-name"
+        onBlur={this.handleBlur}
+        onKeyDown={this.handleKeyDown}
+        onFocus={this.handleFocus}
+      >
+        {this.props.value}
+      </span>
     );
   }
 }

+ 46 - 0
src/components/ExportDialog.css

@@ -0,0 +1,46 @@
+.ExportDialog__dialog {
+  /* transition: opacity 0.15s ease-in, transform 0.15s ease-in; */
+  opacity: 0;
+  transform: translateY(10px);
+  animation: ExportDialog__fade-in 0.1s ease-out 0.05s forwards;
+  position: relative;
+}
+
+@keyframes ExportDialog__fade-in {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.ExportDialog__close {
+  position: absolute;
+  right: calc(var(--space-factor) * 5);
+  top: calc(var(--space-factor) * 5);
+}
+
+.ExportDialog__preview {
+  --preview-padding: calc(var(--space-factor) * 4);
+
+  background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
+    left center;
+  text-align: center;
+  padding: var(--preview-padding);
+  margin-bottom: calc(var(--space-factor) * 3);
+}
+
+.ExportDialog__preview canvas {
+  max-width: calc(100% - var(--preview-padding) * 2);
+  max-height: 400px;
+}
+
+.ExportDialog__actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}

+ 149 - 0
src/components/ExportDialog.tsx

@@ -0,0 +1,149 @@
+import "./ExportDialog.css";
+
+import React, { useState, useEffect, useRef } from "react";
+
+import { Modal } from "./Modal";
+import { ToolIcon } from "./ToolIcon";
+import { clipboard, exportFile, downloadFile } from "./icons";
+import { Island } from "./Island";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+import { getExportCanvasPreview } from "../scene/data";
+import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
+import Stack from "./Stack";
+
+const probablySupportsClipboard =
+  "toBlob" in HTMLCanvasElement.prototype &&
+  "clipboard" in navigator &&
+  "write" in navigator.clipboard &&
+  "ClipboardItem" in window;
+
+export function ExportDialog({
+  elements,
+  appState,
+  exportPadding = 10,
+  actionManager,
+  syncActionResult,
+  onExportToPng,
+  onExportToClipboard
+}: {
+  appState: AppState;
+  elements: readonly ExcalidrawElement[];
+  exportPadding?: number;
+  actionManager: ActionsManagerInterface;
+  syncActionResult: UpdaterFn;
+  onExportToPng(elements: readonly ExcalidrawElement[]): void;
+  onExportToClipboard(elements: readonly ExcalidrawElement[]): void;
+}) {
+  const someElementIsSelected = elements.some(element => element.isSelected);
+  const [modalIsShown, setModalIsShown] = useState(false);
+  const [exportSelected, setExportSelected] = useState(someElementIsSelected);
+  const previeRef = useRef<HTMLDivElement>(null);
+  const { exportBackground, viewBackgroundColor } = appState;
+
+  const exportedElements = exportSelected
+    ? elements.filter(element => element.isSelected)
+    : elements;
+
+  useEffect(() => {
+    setExportSelected(someElementIsSelected);
+  }, [someElementIsSelected]);
+
+  useEffect(() => {
+    const previewNode = previeRef.current;
+    const canvas = getExportCanvasPreview(exportedElements, {
+      exportBackground,
+      viewBackgroundColor,
+      exportPadding
+    });
+    previewNode?.appendChild(canvas);
+    return () => {
+      previewNode?.removeChild(canvas);
+    };
+  }, [
+    modalIsShown,
+    exportedElements,
+    exportBackground,
+    exportPadding,
+    viewBackgroundColor
+  ]);
+
+  function handleClose() {
+    setModalIsShown(false);
+    setExportSelected(someElementIsSelected);
+  }
+
+  return (
+    <>
+      <ToolIcon
+        onClick={() => setModalIsShown(true)}
+        icon={exportFile}
+        type="button"
+        aria-label="Show export dialog"
+      />
+      {modalIsShown && (
+        <Modal maxWidth={640} onCloseRequest={handleClose}>
+          <div className="ExportDialog__dialog">
+            <Island padding={4}>
+              <button className="ExportDialog__close" onClick={handleClose}>
+                ╳
+              </button>
+              <h2>Export</h2>
+              <div className="ExportDialog__preview" ref={previeRef}></div>
+              <div className="ExportDialog__actions">
+                <Stack.Row gap={2}>
+                  <ToolIcon
+                    type="button"
+                    icon={downloadFile}
+                    title="Export to PNG"
+                    aria-label="Export to PNG"
+                    onClick={() => onExportToPng(exportedElements)}
+                  />
+
+                  {probablySupportsClipboard && (
+                    <ToolIcon
+                      type="button"
+                      icon={clipboard}
+                      title="Copy to clipboard"
+                      aria-label="Copy to clipboard"
+                      onClick={() => onExportToClipboard(exportedElements)}
+                    />
+                  )}
+                </Stack.Row>
+
+                {actionManager.renderAction(
+                  "changeProjectName",
+                  elements,
+                  appState,
+                  syncActionResult
+                )}
+                <Stack.Col gap={1}>
+                  {actionManager.renderAction(
+                    "changeExportBackground",
+                    elements,
+                    appState,
+                    syncActionResult
+                  )}
+                  {someElementIsSelected && (
+                    <div>
+                      <label>
+                        <input
+                          type="checkbox"
+                          checked={exportSelected}
+                          onChange={e =>
+                            setExportSelected(e.currentTarget.checked)
+                          }
+                        />{" "}
+                        Only selected
+                      </label>
+                    </div>
+                  )}
+                </Stack.Col>
+              </div>
+            </Island>
+          </div>
+        </Modal>
+      )}
+    </>
+  );
+}

+ 30 - 0
src/components/FixedSideContainer.css

@@ -0,0 +1,30 @@
+.FixedSideContainer {
+  --margin: 5px;
+  position: fixed;
+  pointer-events: none;
+}
+
+.FixedSideContainer > * {
+  pointer-events: all;
+}
+
+.FixedSideContainer_side_top {
+  left: var(--margin);
+  top: var(--margin);
+  right: var(--margin);
+  z-index: 2;
+}
+
+.FixedSideContainer_side_left {
+  left: var(--margin);
+  top: var(--margin);
+  bottom: var(--margin);
+  z-index: 1;
+}
+
+.FixedSideContainer_side_right {
+  right: var(--margin);
+  top: var(--margin);
+  bottom: var(--margin);
+  z-index: 3;
+}

+ 19 - 0
src/components/FixedSideContainer.tsx

@@ -0,0 +1,19 @@
+import "./FixedSideContainer.css";
+
+import React from "react";
+
+type FixedSideContainerProps = {
+  children: React.ReactNode;
+  side: "top" | "left" | "right";
+};
+
+export function FixedSideContainer({
+  children,
+  side
+}: FixedSideContainerProps) {
+  return (
+    <div className={"FixedSideContainer FixedSideContainer_side_" + side}>
+      {children}
+    </div>
+  );
+}

+ 7 - 0
src/components/Island.css

@@ -0,0 +1,7 @@
+.Island {
+  --padding: 0;
+  background-color: var(--bg-color-main);
+  box-shadow: var(--shadow-island);
+  border-radius: var(--border-radius-m);
+  padding: calc(var(--padding) * var(--space-factor));
+}

+ 16 - 0
src/components/Island.tsx

@@ -0,0 +1,16 @@
+import "./Island.css";
+
+import React from "react";
+
+type IslandProps = { children: React.ReactNode; padding?: number };
+
+export function Island({ children, padding }: IslandProps) {
+  return (
+    <div
+      className="Island"
+      style={{ "--padding": padding } as React.CSSProperties}
+    >
+      {children}
+    </div>
+  );
+}

+ 29 - 0
src/components/Modal.css

@@ -0,0 +1,29 @@
+.Modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: auto;
+  padding: calc(var(--space-factor) * 10);
+}
+
+.Modal__background {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1;
+  background-color: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(2px);
+}
+
+.Modal__content {
+  position: relative;
+  z-index: 2;
+  width: 100%;
+}

+ 36 - 0
src/components/Modal.tsx

@@ -0,0 +1,36 @@
+import "./Modal.css";
+
+import React, { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+
+export function Modal(props: {
+  children: React.ReactNode;
+  maxWidth?: number;
+  onCloseRequest(): void;
+}) {
+  const modalRoot = useBodyRoot();
+  return createPortal(
+    <div className="Modal">
+      <div className="Modal__background" onClick={props.onCloseRequest}></div>
+      <div className="Modal__content" style={{ maxWidth: props.maxWidth }}>
+        {props.children}
+      </div>
+    </div>,
+    modalRoot
+  );
+}
+
+function useBodyRoot() {
+  function createDiv() {
+    const div = document.createElement("div");
+    document.body.appendChild(div);
+    return div;
+  }
+  const [div] = useState(createDiv);
+  useEffect(() => {
+    return () => {
+      document.body.removeChild(div);
+    };
+  }, [div]);
+  return div;
+}

+ 0 - 43
src/components/Panel.tsx

@@ -1,43 +0,0 @@
-import React, { useState } from "react";
-
-interface PanelProps {
-  title: string;
-  defaultCollapsed?: boolean;
-  hide?: boolean;
-}
-
-export const Panel: React.FC<PanelProps> = ({
-  title,
-  children,
-  defaultCollapsed = false,
-  hide = false
-}) => {
-  const [collapsed, setCollapsed] = useState(defaultCollapsed);
-
-  if (hide) return null;
-
-  return (
-    <div className="panel">
-      <h4>{title}</h4>
-      <button
-        className="btn-panel-collapse"
-        type="button"
-        onClick={e => {
-          e.preventDefault();
-          setCollapsed(collapsed => !collapsed);
-        }}
-      >
-        {
-          <span
-            className={`btn-panel-collapse-icon ${
-              collapsed ? "btn-panel-collapse-icon-closed" : ""
-            }`}
-          >
-            ▼
-          </span>
-        }
-      </button>
-      {!collapsed && <div className="panelColumn">{children}</div>}
-    </div>
-  );
-};

+ 12 - 0
src/components/Popover.css

@@ -0,0 +1,12 @@
+.popover {
+  position: absolute;
+  z-index: 10;
+}
+
+.popover .cover {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}

+ 1 - 0
src/components/Popover.tsx

@@ -1,4 +1,5 @@
 import React, { useLayoutEffect, useRef } from "react";
+import "./Popover.css";
 
 type Props = {
   top?: number;

+ 0 - 148
src/components/SidePanel.tsx

@@ -1,148 +0,0 @@
-import React from "react";
-import { PanelTools } from "./panels/PanelTools";
-import { Panel } from "./Panel";
-import { PanelSelection } from "./panels/PanelSelection";
-import {
-  hasBackground,
-  someElementIsSelected,
-  hasStroke,
-  hasText,
-  exportCanvas
-} from "../scene";
-import { ExcalidrawElement } from "../element/types";
-import { PanelCanvas } from "./panels/PanelCanvas";
-import { PanelExport } from "./panels/PanelExport";
-import { ExportType } from "../scene/types";
-import { AppState } from "../types";
-import { ActionManager } from "../actions";
-import { UpdaterFn } from "../actions/types";
-
-interface SidePanelProps {
-  actionManager: ActionManager;
-  elements: readonly ExcalidrawElement[];
-  syncActionResult: UpdaterFn;
-  appState: AppState;
-  onToolChange: (elementType: string) => void;
-  canvas: HTMLCanvasElement;
-}
-
-export const SidePanel: React.FC<SidePanelProps> = ({
-  actionManager,
-  syncActionResult,
-  elements,
-  onToolChange,
-  appState,
-  canvas
-}) => {
-  return (
-    <div className="sidePanel">
-      <PanelTools
-        activeTool={appState.elementType}
-        onToolChange={value => {
-          onToolChange(value);
-        }}
-      />
-      <Panel title="Selection" hide={!someElementIsSelected(elements)}>
-        <PanelSelection
-          actionManager={actionManager}
-          syncActionResult={syncActionResult}
-          elements={elements}
-          appState={appState}
-        />
-
-        {actionManager.renderAction(
-          "changeStrokeColor",
-          elements,
-          appState,
-          syncActionResult
-        )}
-
-        {hasBackground(elements) && (
-          <>
-            {actionManager.renderAction(
-              "changeBackgroundColor",
-              elements,
-              appState,
-              syncActionResult
-            )}
-
-            {actionManager.renderAction(
-              "changeFillStyle",
-              elements,
-              appState,
-              syncActionResult
-            )}
-          </>
-        )}
-
-        {hasStroke(elements) && (
-          <>
-            {actionManager.renderAction(
-              "changeStrokeWidth",
-              elements,
-              appState,
-              syncActionResult
-            )}
-
-            {actionManager.renderAction(
-              "changeSloppiness",
-              elements,
-              appState,
-              syncActionResult
-            )}
-          </>
-        )}
-
-        {hasText(elements) && (
-          <>
-            {actionManager.renderAction(
-              "changeFontSize",
-              elements,
-              appState,
-              syncActionResult
-            )}
-
-            {actionManager.renderAction(
-              "changeFontFamily",
-              elements,
-              appState,
-              syncActionResult
-            )}
-          </>
-        )}
-
-        {actionManager.renderAction(
-          "changeOpacity",
-          elements,
-          appState,
-          syncActionResult
-        )}
-
-        {actionManager.renderAction(
-          "deleteSelectedElements",
-          elements,
-          appState,
-          syncActionResult
-        )}
-      </Panel>
-      <PanelCanvas
-        actionManager={actionManager}
-        syncActionResult={syncActionResult}
-        elements={elements}
-        appState={appState}
-      />
-      <PanelExport
-        actionManager={actionManager}
-        syncActionResult={syncActionResult}
-        elements={elements}
-        appState={appState}
-        onExportCanvas={(type: ExportType) => {
-          const exportedElements = elements.some(element => element.isSelected)
-            ? elements.filter(element => element.isSelected)
-            : elements;
-          return exportCanvas(type, exportedElements, canvas, appState);
-        }}
-      />
-    </div>
-  );
-};

+ 17 - 0
src/components/Stack.css

@@ -0,0 +1,17 @@
+.Stack {
+  --gap: 0;
+  display: grid;
+  gap: calc(var(--space-factor) * var(--gap));
+}
+
+.Stack_vertical {
+  grid-template-columns: auto;
+  grid-auto-flow: row;
+  grid-auto-rows: min-content;
+}
+
+.Stack_horizontal {
+  grid-template-rows: auto;
+  grid-auto-flow: column;
+  grid-auto-columns: min-content;
+}

+ 36 - 0
src/components/Stack.tsx

@@ -0,0 +1,36 @@
+import "./Stack.css";
+
+import React from "react";
+
+type StackProps = {
+  children: React.ReactNode;
+  gap?: number;
+  align?: "start" | "center" | "end";
+};
+
+function RowStack({ children, gap, align }: StackProps) {
+  return (
+    <div
+      className="Stack Stack_horizontal"
+      style={{ "--gap": gap, alignItems: align } as React.CSSProperties}
+    >
+      {children}
+    </div>
+  );
+}
+
+function ColStack({ children, gap, align }: StackProps) {
+  return (
+    <div
+      className="Stack Stack_vertical"
+      style={{ "--gap": gap, justifyItems: align } as React.CSSProperties}
+    >
+      {children}
+    </div>
+  );
+}
+
+export default {
+  Row: RowStack,
+  Col: ColStack
+};

+ 53 - 0
src/components/ToolIcon.scss

@@ -0,0 +1,53 @@
+.ToolIcon {
+  display: inline-block;
+  position: relative;
+}
+
+.ToolIcon__icon {
+  background-color: #ddd;
+
+  width: 41px;
+  height: 41px;
+
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  border-radius: var(--space-factor);
+
+  svg {
+    height: 1em;
+  }
+}
+
+.ToolIcon_type_button {
+  padding: 0;
+  border: none;
+  margin: 0;
+  font-size: inherit;
+
+  &:hover .ToolIcon__icon {
+    background-color: #e7e5e5;
+  }
+  &:active .ToolIcon__icon {
+    background-color: #bdbebc;
+  }
+  &:focus .ToolIcon__icon {
+    box-shadow: 0 0 0 2px steelblue;
+  }
+}
+
+.ToolIcon_type_radio {
+  position: absolute;
+  opacity: 0;
+
+  &:hover + .ToolIcon__icon {
+    background-color: #e7e5e5;
+  }
+  &:checked + .ToolIcon__icon {
+    background-color: #bdbebc;
+  }
+  &:focus + .ToolIcon__icon {
+    box-shadow: 0 0 0 2px steelblue;
+  }
+}

+ 53 - 0
src/components/ToolIcon.tsx

@@ -0,0 +1,53 @@
+import "./ToolIcon.scss";
+
+import React from "react";
+
+type ToolIconProps =
+  | {
+      type: "button";
+      icon: React.ReactNode;
+      "aria-label": string;
+      title?: string;
+      name?: string;
+      id?: string;
+      onClick?(): void;
+    }
+  | {
+      type: "radio";
+      icon: React.ReactNode;
+      title?: string;
+      name?: string;
+      id?: string;
+      checked: boolean;
+      onChange?(): void;
+    };
+
+export function ToolIcon(props: ToolIconProps) {
+  if (props.type === "button")
+    return (
+      <label className="ToolIcon" title={props.title}>
+        <button
+          className="ToolIcon_type_button"
+          aria-label={props["aria-label"]}
+          type="button"
+          onClick={props.onClick}
+        >
+          <div className="ToolIcon__icon">{props.icon}</div>
+        </button>
+      </label>
+    );
+
+  return (
+    <label className="ToolIcon" title={props.title}>
+      <input
+        className="ToolIcon_type_radio"
+        type="radio"
+        name={props.name}
+        id={props.id}
+        onChange={props.onChange}
+        checked={props.checked}
+      />
+      <div className="ToolIcon__icon">{props.icon}</div>
+    </label>
+  );
+}

+ 78 - 0
src/components/icons.tsx

@@ -0,0 +1,78 @@
+//
+// All icons are imported from https://fontawesome.com/icons?d=gallery
+// Icons are under the license https://fontawesome.com/license
+//
+
+import React from "react";
+
+export const save = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
+    <path
+      fill="currentColor"
+      d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
+    />
+  </svg>
+);
+
+export const load = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
+    <path
+      fill="currentColor"
+      d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
+    />
+  </svg>
+);
+
+export const image = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
+    <path
+      fill="currentColor"
+      d="M384 121.941V128H256V0h6.059a24 24 0 0 1 16.97 7.029l97.941 97.941a24.002 24.002 0 0 1 7.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
+    />
+  </svg>
+);
+
+export const clipboard = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
+    <path
+      fill="currentColor"
+      d="M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z"
+    />
+  </svg>
+);
+
+export const trash = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
+    <path
+      fill="currentColor"
+      d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
+    />
+  </svg>
+);
+
+export const palete = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512">
+    <path
+      fill="currentColor"
+      d="M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"
+    />
+  </svg>
+);
+
+export const exportFile = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
+    <path
+      fill="currentColor"
+      d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z"
+    />
+  </svg>
+);
+
+export const downloadFile = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
+    <path
+      fill="currentColor"
+      d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm76.45 211.36l-96.42 95.7c-6.65 6.61-17.39 6.61-24.04 0l-96.42-95.7C73.42 337.29 80.54 320 94.82 320H160v-80c0-8.84 7.16-16 16-16h32c8.84 0 16 7.16 16 16v80h65.18c14.28 0 21.4 17.29 11.27 27.36zM377 105L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1c0-6.3-2.5-12.4-7-16.9z"
+    />
+  </svg>
+);

+ 0 - 39
src/components/panels/PanelCanvas.tsx

@@ -1,39 +0,0 @@
-import React from "react";
-
-import { Panel } from "../Panel";
-import { ActionManager } from "../../actions";
-import { ExcalidrawElement } from "../../element/types";
-import { AppState } from "../../types";
-import { UpdaterFn } from "../../actions/types";
-
-interface PanelCanvasProps {
-  actionManager: ActionManager;
-  elements: readonly ExcalidrawElement[];
-  appState: AppState;
-  syncActionResult: UpdaterFn;
-}
-
-export const PanelCanvas: React.FC<PanelCanvasProps> = ({
-  actionManager,
-  elements,
-  appState,
-  syncActionResult
-}) => {
-  return (
-    <Panel title="Canvas">
-      {actionManager.renderAction(
-        "changeViewBackgroundColor",
-        elements,
-        appState,
-        syncActionResult
-      )}
-
-      {actionManager.renderAction(
-        "clearCanvas",
-        elements,
-        appState,
-        syncActionResult
-      )}
-    </Panel>
-  );
-};

+ 0 - 27
src/components/panels/PanelColor.tsx

@@ -1,27 +0,0 @@
-import React from "react";
-import { ColorPicker } from "../ColorPicker";
-
-interface PanelColorProps {
-  title: string;
-  colorType: "canvasBackground" | "elementBackground" | "elementStroke";
-  colorValue: string | null;
-  onColorChange: (value: string) => void;
-}
-
-export const PanelColor: React.FC<PanelColorProps> = ({
-  title,
-  colorType,
-  onColorChange,
-  colorValue
-}) => {
-  return (
-    <>
-      <h5>{title}</h5>
-      <ColorPicker
-        type={colorType}
-        color={colorValue}
-        onChange={color => onColorChange(color)}
-      />
-    </>
-  );
-};

+ 0 - 92
src/components/panels/PanelExport.tsx

@@ -1,92 +0,0 @@
-import React from "react";
-import { Panel } from "../Panel";
-import { ExportType } from "../../scene/types";
-
-import "./panelExport.scss";
-import { ActionManager } from "../../actions";
-import { ExcalidrawElement } from "../../element/types";
-import { AppState } from "../../types";
-import { UpdaterFn } from "../../actions/types";
-
-interface PanelExportProps {
-  actionManager: ActionManager;
-  elements: readonly ExcalidrawElement[];
-  appState: AppState;
-  syncActionResult: UpdaterFn;
-  onExportCanvas: (type: ExportType) => void;
-}
-
-// fa-clipboard
-const ClipboardIcon = () => (
-  <svg viewBox="0 0 384 512">
-    <path
-      fill="currentColor"
-      d="M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z"
-    ></path>
-  </svg>
-);
-
-const probablySupportsClipboard =
-  "toBlob" in HTMLCanvasElement.prototype &&
-  "clipboard" in navigator &&
-  "write" in navigator.clipboard &&
-  "ClipboardItem" in window;
-
-export const PanelExport: React.FC<PanelExportProps> = ({
-  actionManager,
-  elements,
-  appState,
-  syncActionResult,
-  onExportCanvas
-}) => {
-  return (
-    <Panel title="Export">
-      <div className="panelColumn">
-        {actionManager.renderAction(
-          "changeProjectName",
-          elements,
-          appState,
-          syncActionResult
-        )}
-        <h5>Image</h5>
-        <div className="panelExport-imageButtons">
-          <button
-            className="panelExport-exportToPngButton"
-            onClick={() => onExportCanvas("png")}
-          >
-            Export to PNG
-          </button>
-          {probablySupportsClipboard && (
-            <button
-              className="panelExport-exportToClipboardButton"
-              onClick={() => onExportCanvas("clipboard")}
-              title="Copy to clipboard (experimental)"
-            >
-              <ClipboardIcon />
-            </button>
-          )}
-        </div>
-        {actionManager.renderAction(
-          "changeExportBackground",
-          elements,
-          appState,
-          syncActionResult
-        )}
-
-        <h5>Scene</h5>
-        {actionManager.renderAction(
-          "saveScene",
-          elements,
-          appState,
-          syncActionResult
-        )}
-        {actionManager.renderAction(
-          "loadScene",
-          elements,
-          appState,
-          syncActionResult
-        )}
-      </div>
-    </Panel>
-  );
-};

+ 0 - 50
src/components/panels/PanelSelection.tsx

@@ -1,50 +0,0 @@
-import React from "react";
-import { ActionManager } from "../../actions";
-import { ExcalidrawElement } from "../../element/types";
-import { AppState } from "../../types";
-import { UpdaterFn } from "../../actions/types";
-
-interface PanelSelectionProps {
-  actionManager: ActionManager;
-  elements: readonly ExcalidrawElement[];
-  appState: AppState;
-  syncActionResult: UpdaterFn;
-}
-
-export const PanelSelection: React.FC<PanelSelectionProps> = ({
-  actionManager,
-  elements,
-  appState,
-  syncActionResult
-}) => {
-  return (
-    <div>
-      <div className="buttonList">
-        {actionManager.renderAction(
-          "bringForward",
-          elements,
-          appState,
-          syncActionResult
-        )}
-        {actionManager.renderAction(
-          "bringToFront",
-          elements,
-          appState,
-          syncActionResult
-        )}
-        {actionManager.renderAction(
-          "sendBackward",
-          elements,
-          appState,
-          syncActionResult
-        )}
-        {actionManager.renderAction(
-          "sendToBack",
-          elements,
-          appState,
-          syncActionResult
-        )}
-      </div>
-    </div>
-  );
-};

+ 0 - 38
src/components/panels/PanelTools.tsx

@@ -1,38 +0,0 @@
-import React from "react";
-
-import { SHAPES } from "../../shapes";
-import { capitalizeString } from "../../utils";
-import { Panel } from "../Panel";
-
-interface PanelToolsProps {
-  activeTool: string;
-  onToolChange: (value: string) => void;
-}
-
-export const PanelTools: React.FC<PanelToolsProps> = ({
-  activeTool,
-  onToolChange
-}) => {
-  return (
-    <Panel title="Shapes">
-      <div className="panelTools">
-        {SHAPES.map(({ value, icon }) => (
-          <label
-            key={value}
-            className="tool"
-            title={`${capitalizeString(value)} - ${capitalizeString(value)[0]}`}
-          >
-            <input
-              type="radio"
-              checked={activeTool === value}
-              onChange={() => {
-                onToolChange(value);
-              }}
-            />
-            <div className="toolIcon">{icon}</div>
-          </label>
-        ))}
-      </div>
-    </Panel>
-  );
-};

+ 0 - 16
src/components/panels/panelExport.scss

@@ -1,16 +0,0 @@
-.panelExport-imageButtons {
-  display: flex;
-}
-
-.panelExport-exportToPngButton {
-  flex: 1 1 auto;
-}
-
-.panelExport-exportToClipboardButton {
-  margin-left: 10px;
-  padding: 0 15px;
-
-  svg {
-    width: 15px;
-  }
-}

+ 2 - 7
src/element/textWysiwyg.tsx

@@ -1,4 +1,5 @@
 import { KEYS } from "../keys";
+import { selectNode } from "../utils";
 
 type TextWysiwygParams = {
   initText: string;
@@ -89,11 +90,5 @@ export function textWysiwyg({
   window.addEventListener("wheel", stopEvent, true);
   document.body.appendChild(editable);
   editable.focus();
-  const selection = window.getSelection();
-  if (selection) {
-    const range = document.createRange();
-    range.selectNodeContents(editable);
-    selection.removeAllRanges();
-    selection.addRange(range);
-  }
+  selectNode(editable);
 }

+ 198 - 20
src/index.tsx

@@ -21,17 +21,21 @@ import {
   saveToLocalStorage,
   getElementAtPosition,
   createScene,
-  getElementContainingPosition
+  getElementContainingPosition,
+  hasBackground,
+  hasStroke,
+  hasText,
+  exportCanvas
 } from "./scene";
 
 import { renderScene } from "./renderer";
 import { AppState } from "./types";
 import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
 
-import { isInputLike, measureText, debounce } from "./utils";
+import { isInputLike, measureText, debounce, capitalizeString } from "./utils";
 import { KEYS, META_KEY, isArrowKey } from "./keys";
 
-import { findShapeByKey, shapesShortcutKeys } from "./shapes";
+import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
 import { createHistory } from "./history";
 
 import ContextMenu from "./components/ContextMenu";
@@ -63,14 +67,18 @@ import {
   actionCopyStyles,
   actionPasteStyles
 } from "./actions";
-import { SidePanel } from "./components/SidePanel";
 import { Action, ActionResult } from "./actions/types";
 import { getDefaultAppState } from "./appState";
+import { Island } from "./components/Island";
+import Stack from "./components/Stack";
+import { FixedSideContainer } from "./components/FixedSideContainer";
+import { ToolIcon } from "./components/ToolIcon";
+import { ExportDialog } from "./components/ExportDialog";
 
 let { elements } = createScene();
 const { history } = createHistory();
 
-const CANVAS_WINDOW_OFFSET_LEFT = 250;
+const CANVAS_WINDOW_OFFSET_LEFT = 0;
 const CANVAS_WINDOW_OFFSET_TOP = 0;
 
 function resetCursor() {
@@ -331,26 +339,197 @@ export class App extends React.Component<{}, AppState> {
     }
   };
 
+  private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
+    const selectedElements = elements.filter(el => el.isSelected);
+    if (selectedElements.length === 0) {
+      return null;
+    }
+
+    return (
+      <Island padding={4}>
+        <div className="panelColumn">
+          {this.actionManager.renderAction(
+            "changeStrokeColor",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+
+          {hasBackground(elements) && (
+            <>
+              {this.actionManager.renderAction(
+                "changeBackgroundColor",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+
+              {this.actionManager.renderAction(
+                "changeFillStyle",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+              <hr />
+            </>
+          )}
+
+          {hasStroke(elements) && (
+            <>
+              {this.actionManager.renderAction(
+                "changeStrokeWidth",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+
+              {this.actionManager.renderAction(
+                "changeSloppiness",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+              <hr />
+            </>
+          )}
+
+          {hasText(elements) && (
+            <>
+              {this.actionManager.renderAction(
+                "changeFontSize",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+
+              {this.actionManager.renderAction(
+                "changeFontFamily",
+                elements,
+                this.state,
+                this.syncActionResult
+              )}
+              <hr />
+            </>
+          )}
+
+          {this.actionManager.renderAction(
+            "changeOpacity",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+
+          {this.actionManager.renderAction(
+            "deleteSelectedElements",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+        </div>
+      </Island>
+    );
+  }
+
+  private renderShapesSwitcher() {
+    return (
+      <>
+        {SHAPES.map(({ value, icon }) => (
+          <ToolIcon
+            key={value}
+            type="radio"
+            icon={icon}
+            checked={this.state.elementType === value}
+            name="editor-current-shape"
+            title={`${capitalizeString(value)} — ${capitalizeString(value)[0]}`}
+            onChange={() => {
+              this.setState({ elementType: value });
+              elements = clearSelection(elements);
+              document.documentElement.style.cursor =
+                value === "text" ? "text" : "crosshair";
+              this.forceUpdate();
+            }}
+          ></ToolIcon>
+        ))}
+      </>
+    );
+  }
+
+  private renderCanvasActions() {
+    return (
+      <Stack.Col gap={4}>
+        <Stack.Row gap={1}>
+          {this.actionManager.renderAction(
+            "loadScene",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+          {this.actionManager.renderAction(
+            "saveScene",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+          <ExportDialog
+            elements={elements}
+            appState={this.state}
+            actionManager={this.actionManager}
+            syncActionResult={this.syncActionResult}
+            onExportToPng={exportedElements => {
+              if (this.canvas)
+                exportCanvas("png", exportedElements, this.canvas, this.state);
+            }}
+            onExportToClipboard={exportedElements => {
+              if (this.canvas)
+                exportCanvas(
+                  "clipboard",
+                  exportedElements,
+                  this.canvas,
+                  this.state
+                );
+            }}
+          />
+          {this.actionManager.renderAction(
+            "clearCanvas",
+            elements,
+            this.state,
+            this.syncActionResult
+          )}
+        </Stack.Row>
+        {this.actionManager.renderAction(
+          "changeViewBackgroundColor",
+          elements,
+          this.state,
+          this.syncActionResult
+        )}
+      </Stack.Col>
+    );
+  }
+
   public render() {
     const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
     const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
 
     return (
       <div className="container">
-        <SidePanel
-          actionManager={this.actionManager}
-          syncActionResult={this.syncActionResult}
-          appState={{ ...this.state }}
-          elements={elements}
-          onToolChange={value => {
-            this.setState({ elementType: value });
-            elements = clearSelection(elements);
-            document.documentElement.style.cursor =
-              value === "text" ? "text" : "crosshair";
-            this.forceUpdate();
-          }}
-          canvas={this.canvas!}
-        />
+        <FixedSideContainer side="top">
+          <div className="App-menu App-menu_top">
+            <Stack.Col gap={4} align="end">
+              <div className="App-right-menu">
+                <Island padding={4}>{this.renderCanvasActions()}</Island>
+              </div>
+              <div className="App-right-menu">
+                {this.renderSelectedShapeActions(elements)}
+              </div>
+            </Stack.Col>
+            <Stack.Col gap={4} align="start">
+              <Island padding={1}>
+                <Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
+              </Island>
+            </Stack.Col>
+            <div />
+          </div>
+        </FixedSideContainer>
         <canvas
           id="canvas"
           style={{
@@ -374,7 +553,6 @@ export class App extends React.Component<{}, AppState> {
               });
               this.removeWheelEventListener = () =>
                 canvas.removeEventListener("wheel", this.handleWheel);
-
               // Whenever React sets the width/height of the canvas element,
               // the context loses the scale transform. We need to re-apply it
               if (

+ 53 - 0
src/scene/data.ts

@@ -77,6 +77,59 @@ export function loadFromJSON() {
   });
 }
 
+export function getExportCanvasPreview(
+  elements: readonly ExcalidrawElement[],
+  {
+    exportBackground,
+    exportPadding = 10,
+    viewBackgroundColor
+  }: {
+    exportBackground: boolean;
+    exportPadding?: number;
+    viewBackgroundColor: string;
+  }
+) {
+  // calculate smallest area to fit the contents in
+  let subCanvasX1 = Infinity;
+  let subCanvasX2 = 0;
+  let subCanvasY1 = Infinity;
+  let subCanvasY2 = 0;
+
+  elements.forEach(element => {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    subCanvasX1 = Math.min(subCanvasX1, x1);
+    subCanvasY1 = Math.min(subCanvasY1, y1);
+    subCanvasX2 = Math.max(subCanvasX2, x2);
+    subCanvasY2 = Math.max(subCanvasY2, y2);
+  });
+
+  function distance(x: number, y: number) {
+    return Math.abs(x > y ? x - y : y - x);
+  }
+
+  const tempCanvas = document.createElement("canvas");
+  tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2;
+  tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2;
+
+  renderScene(
+    elements,
+    rough.canvas(tempCanvas),
+    tempCanvas,
+    {
+      viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+      scrollX: 0,
+      scrollY: 0
+    },
+    {
+      offsetX: -subCanvasX1 + exportPadding,
+      offsetY: -subCanvasY1 + exportPadding,
+      renderScrollbars: false,
+      renderSelection: false
+    }
+  );
+  return tempCanvas;
+}
+
 export function exportCanvas(
   type: ExportType,
   elements: readonly ExcalidrawElement[],

+ 51 - 149
src/styles.scss

@@ -1,3 +1,5 @@
+@import "./theme.css";
+
 /* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
 @font-face {
   font-family: "Virgil";
@@ -8,6 +10,7 @@
 body {
   margin: 0;
   font-family: Arial, Helvetica, sans-serif;
+  color: var(--text-color-primary);
 }
 
 .container {
@@ -19,138 +22,39 @@ body {
   right: 0;
 }
 
-.sidePanel {
-  width: 230px;
-  background-color: #eee;
-  padding: 10px;
-  overflow-y: auto;
-  position: relative;
+.panelColumn {
+  display: flex;
+  flex-direction: column;
 
-  h4 {
-    margin: 10px 0 10px 0;
+  h5 {
+    margin-top: 4px;
+    margin-bottom: 4px;
+    font-size: 12px;
+    color: #333;
   }
 
-  .panel {
-    position: relative;
-    .btn-panel-collapse {
-      position: absolute;
-      top: -2px;
-      right: 5px;
-      background: none;
-      margin: 0px;
-      color: black;
-    }
-
-    .btn-panel-collapse-icon {
-      transform: none;
-      display: inline-block;
-    }
-
-    .btn-panel-collapse-icon-closed {
-      transform: rotateZ(90deg);
-    }
+  h5:first-child {
+    margin-top: 0;
   }
 
-  .panelTools {
-    display: flex;
+  .buttonList {
     flex-wrap: wrap;
-    justify-content: space-between;
-
-    label {
-      margin: 2px 0;
-    }
-  }
-
-  .panelColumn {
-    display: flex;
-    flex-direction: column;
-
-    h5 {
-      margin-top: 4px;
-      margin-bottom: 4px;
-      font-size: 12px;
-      color: #333;
-    }
-
-    h5:first-child {
-      margin-top: 0;
-    }
-
-    .buttonList {
-      flex-wrap: wrap;
-
-      button {
-        margin-right: 4px;
-      }
-    }
-  }
-}
-
-.tool {
-  position: relative;
-
-  input[type="radio"] {
-    position: absolute;
-    opacity: 0;
-    width: 0;
-    height: 0;
-  }
-
-  input[type="radio"] {
-    & + .toolIcon {
-      background-color: #ddd;
-
-      width: 41px;
-      height: 41px;
 
-      display: flex;
-      justify-content: center;
-      align-items: center;
-
-      border-radius: 3px;
-
-      svg {
-        height: 1em;
-      }
-    }
-    &:hover + .toolIcon {
-      background-color: #e7e5e5;
-    }
-    &:checked + .toolIcon {
-      background-color: #bdbebc;
-    }
-    &:focus + .toolIcon {
-      box-shadow: 0 0 0 2px steelblue;
+    button {
+      margin-right: 4px;
     }
   }
 }
 
-label {
-  margin-right: 6px;
-  span {
-    display: inline-block;
-  }
-}
-
-input[type="number"] {
-  width: 30px;
-}
-
-input[type="color"] {
-  margin: 2px;
-}
-
-input[type="range"] {
-  width: 230px;
+.divider {
+  width: 1px;
+  background-color: #ddd;
+  margin: 1px;
 }
 
-input {
-  margin-right: 5px;
-
-  &:focus {
-    outline: transparent;
-    box-shadow: 0 0 0 2px steelblue;
-  }
+input:focus {
+  outline: transparent;
+  box-shadow: 0 0 0 2px steelblue;
 }
 
 button {
@@ -170,8 +74,7 @@ button {
     border-color: #d6d4d4;
   }
 
-  &:active,
-  &.active {
+  &:active {
     background-color: #bdbebc;
     border-color: #bdbebc;
   }
@@ -181,40 +84,39 @@ button {
   }
 }
 
-.popover {
-  position: absolute;
-  z-index: 2;
+.App-menu {
+  display: grid;
+}
+
+.App-menu_top {
+  grid-template-columns: 1fr auto 1fr;
+  align-items: flex-start;
+  cursor: default;
+  pointer-events: none !important;
+}
 
-  .cover {
-    position: fixed;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: 0;
-  }
+.App-menu_top > * {
+  pointer-events: all;
 }
 
-.swatch {
-  height: 24px;
-  width: 24px;
-  display: inline;
-  margin-right: 4px;
+.App-menu_top > *:first-child {
+  justify-self: flex-start;
 }
 
-.swatch-input {
-  font-size: 16px;
-  display: inline;
-  width: 100px;
-  border-radius: 2px;
-  padding: 2px 4px;
-  border: 1px solid #ddd;
+.App-menu_top > *:last-child {
+  justify-self: flex-end;
 }
-.project-name {
-  font-size: 14px;
-  cursor: pointer;
+
+.App-menu_left {
+  grid-template-rows: 1fr auto 1fr;
+  height: 100%;
+}
+
+.App-menu_right {
+  grid-template-rows: 1fr;
+  height: 100%;
 }
 
-.project-name-input {
-  width: 200px;
-  font: inherit;
+.App-right-menu {
+  width: 220px;
 }

+ 11 - 0
src/theme.css

@@ -0,0 +1,11 @@
+:root {
+  --text-color-primary: #333;
+
+  --bg-color-main: white;
+
+  --shadow-island: 0 1px 5px rgba(0, 0, 0, 0.15);
+
+  --border-radius-m: 4px;
+
+  --space-factor: 4px;
+}

+ 17 - 0
src/utils.ts

@@ -62,3 +62,20 @@ export function debounce<T extends any[]>(
     handle = window.setTimeout(() => fn(...args), timeout);
   };
 }
+
+export function selectNode(node: Element) {
+  const selection = window.getSelection();
+  if (selection) {
+    const range = document.createRange();
+    range.selectNodeContents(node);
+    selection.removeAllRanges();
+    selection.addRange(range);
+  }
+}
+
+export function removeSelection() {
+  const selection = window.getSelection();
+  if (selection) {
+    selection.removeAllRanges();
+  }
+}