Pārlūkot izejas kodu

Context menu with some commands (#217)

Timur Khazamov 5 gadi atpakaļ
vecāks
revīzija
257f697a98

+ 3 - 3
src/components/ColorPicker.tsx

@@ -1,5 +1,6 @@
 import React from "react";
 import { TwitterPicker } from "react-color";
+import { Popover } from "./Popover";
 
 export function ColorPicker({
   color,
@@ -17,8 +18,7 @@ export function ColorPicker({
         onClick={() => setActive(!isActive)}
       />
       {isActive ? (
-        <div className="popover">
-          <div className="cover" onClick={() => setActive(false)} />
+        <Popover onCloseRequest={() => setActive(false)}>
           <TwitterPicker
             colors={[
               "#000000",
@@ -39,7 +39,7 @@ export function ColorPicker({
               onChange(changedColor.hex);
             }}
           />
-        </div>
+        </Popover>
       ) : null}
       <input
         type="text"

+ 34 - 0
src/components/ContextMenu.css

@@ -0,0 +1,34 @@
+.context-menu {
+  position: relative;
+  border-radius: 4px;
+  box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.2);
+  padding: 0;
+  list-style: none;
+  user-select: none;
+}
+
+.context-menu__option {
+  width: 150px;
+}
+
+.context-menu-option {
+  position: relative;
+  width: 100%;
+  margin: 0;
+  text-align: left;
+  border-radius: 0;
+}
+
+.context-menu-option:focus {
+  z-index: 1;
+}
+
+.context-menu__option:first-child .context-menu-option {
+  border-top-left-radius: 4px;
+  border-top-right-radius: 4px;
+}
+
+.context-menu__option:last-child .context-menu-option {
+  border-bottom-left-radius: 4px;
+  border-bottom-right-radius: 4px;
+}

+ 85 - 0
src/components/ContextMenu.tsx

@@ -0,0 +1,85 @@
+import React from "react";
+import { Popover } from "./Popover";
+import { render, unmountComponentAtNode } from "react-dom";
+
+import "./ContextMenu.css";
+
+type ContextMenuOption = {
+  label: string;
+  action(): void;
+};
+
+type Props = {
+  options: ContextMenuOption[];
+  onCloseRequest?(): void;
+  top: number;
+  left: number;
+};
+
+function ContextMenu({ options, onCloseRequest, top, left }: Props) {
+  return (
+    <Popover onCloseRequest={onCloseRequest} top={top} left={left}>
+      <ul className="context-menu" onContextMenu={e => e.preventDefault()}>
+        {options.map((option, idx) => (
+          <li
+            key={idx}
+            className="context-menu__option"
+            onClick={onCloseRequest}
+          >
+            <ContextMenuOption {...option} />
+          </li>
+        ))}
+      </ul>
+    </Popover>
+  );
+}
+
+function ContextMenuOption({ label, action }: ContextMenuOption) {
+  return (
+    <button className="context-menu-option" onClick={action}>
+      {label}
+    </button>
+  );
+}
+
+let contextMenuNode: HTMLDivElement;
+function getContextMenuNode(): HTMLDivElement {
+  if (contextMenuNode) {
+    return contextMenuNode;
+  }
+  const div = document.createElement("div");
+  document.body.appendChild(div);
+  return (contextMenuNode = div);
+}
+
+type ContextMenuParams = {
+  options: (ContextMenuOption | false | null | undefined)[];
+  top: number;
+  left: number;
+};
+
+function handleClose() {
+  unmountComponentAtNode(getContextMenuNode());
+}
+
+export default {
+  push(params: ContextMenuParams) {
+    const options = Array.of<ContextMenuOption>();
+    params.options.forEach(option => {
+      if (option) {
+        options.push(option);
+      }
+    });
+    if (options.length) {
+      render(
+        <ContextMenu
+          top={params.top}
+          left={params.left}
+          options={options}
+          onCloseRequest={handleClose}
+        />,
+        getContextMenuNode()
+      );
+    }
+  }
+};

+ 24 - 0
src/components/Popover.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+
+type Props = {
+  top?: number;
+  left?: number;
+  children?: React.ReactNode;
+  onCloseRequest?(): void;
+};
+
+export function Popover({ children, left, onCloseRequest, top }: Props) {
+  return (
+    <div className="popover" style={{ top: top, left: left }}>
+      <div
+        className="cover"
+        onClick={onCloseRequest}
+        onContextMenu={e => {
+          e.preventDefault();
+          if (onCloseRequest) onCloseRequest();
+        }}
+      />
+      {children}
+    </div>
+  );
+}

+ 102 - 21
src/index.tsx

@@ -36,6 +36,7 @@ import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes";
 import { createHistory } from "./history";
 
 import "./styles.scss";
+import ContextMenu from "./components/ContextMenu";
 
 const { elements } = createScene();
 const { history } = createHistory();
@@ -147,8 +148,7 @@ class App extends React.Component<{}, AppState> {
       this.forceUpdate();
       event.preventDefault();
     } else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
-      deleteSelectedElements(elements);
-      this.forceUpdate();
+      this.deleteSelectedElements();
       event.preventDefault();
     } else if (isArrowKey(event.key)) {
       const step = event.shiftKey
@@ -307,6 +307,23 @@ class App extends React.Component<{}, AppState> {
     this.setState({ currentItemBackgroundColor: color });
   };
 
+  private copyToClipboard = () => {
+    if (navigator.clipboard) {
+      const text = JSON.stringify(
+        elements.filter(element => element.isSelected)
+      );
+      navigator.clipboard.writeText(text);
+    }
+  };
+
+  private pasteFromClipboard = (x?: number, y?: number) => {
+    if (navigator.clipboard) {
+      navigator.clipboard
+        .readText()
+        .then(text => this.addElementsFromPaste(text, x, y));
+    }
+  };
+
   public render() {
     const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
     const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
@@ -332,25 +349,7 @@ class App extends React.Component<{}, AppState> {
         }}
         onPaste={e => {
           const paste = e.clipboardData.getData("text");
-          let parsedElements;
-          try {
-            parsedElements = JSON.parse(paste);
-          } catch (e) {}
-          if (
-            Array.isArray(parsedElements) &&
-            parsedElements.length > 0 &&
-            parsedElements[0].type // need to implement a better check here...
-          ) {
-            clearSelection(elements);
-            parsedElements.forEach(parsedElement => {
-              parsedElement.x = 10 - this.state.scrollX;
-              parsedElement.y = 10 - this.state.scrollY;
-              parsedElement.seed = randomSeed();
-              generateDraw(parsedElement);
-              elements.push(parsedElement);
-            });
-            this.forceUpdate();
-          }
+          this.addElementsFromPaste(paste);
           e.preventDefault();
         }}
       >
@@ -577,6 +576,54 @@ class App extends React.Component<{}, AppState> {
               }
             }
           }}
+          onContextMenu={e => {
+            e.preventDefault();
+
+            const x =
+              e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
+            const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
+
+            const element = getElementAtPosition(elements, x, y);
+            if (!element) {
+              ContextMenu.push({
+                options: [
+                  navigator.clipboard && {
+                    label: "Paste",
+                    action: () => this.pasteFromClipboard(x, y)
+                  }
+                ],
+                top: e.clientY,
+                left: e.clientX
+              });
+              return;
+            }
+
+            if (!element.isSelected) {
+              clearSelection(elements);
+              element.isSelected = true;
+              this.forceUpdate();
+            }
+
+            ContextMenu.push({
+              options: [
+                navigator.clipboard && {
+                  label: "Copy",
+                  action: this.copyToClipboard
+                },
+                navigator.clipboard && {
+                  label: "Paste",
+                  action: () => this.pasteFromClipboard(x, y)
+                },
+                { label: "Delete", action: this.deleteSelectedElements },
+                { label: "Move Forward", action: this.moveOneRight },
+                { label: "Send to Front", action: this.moveAllRight },
+                { label: "Move Backwards", action: this.moveOneLeft },
+                { label: "Send to Back", action: this.moveAllLeft }
+              ],
+              top: e.clientY,
+              left: e.clientX
+            });
+          }}
           onMouseDown={e => {
             if (lastMouseUp !== null) {
               // Unfortunately, sometimes we don't get a mouseup after a mousedown,
@@ -942,6 +989,40 @@ class App extends React.Component<{}, AppState> {
     }));
   };
 
+  private addElementsFromPaste = (paste: string, x?: number, y?: number) => {
+    let parsedElements;
+    try {
+      parsedElements = JSON.parse(paste);
+    } catch (e) {}
+    if (
+      Array.isArray(parsedElements) &&
+      parsedElements.length > 0 &&
+      parsedElements[0].type // need to implement a better check here...
+    ) {
+      clearSelection(elements);
+
+      let dx: number;
+      let dy: number;
+      if (x) {
+        let minX = Math.min(...parsedElements.map(element => element.x));
+        dx = x - minX;
+      }
+      if (y) {
+        let minY = Math.min(...parsedElements.map(element => element.y));
+        dy = y - minY;
+      }
+
+      parsedElements.forEach(parsedElement => {
+        parsedElement.x = dx ? parsedElement.x + dx : 10 - this.state.scrollX;
+        parsedElement.y = dy ? parsedElement.y + dy : 10 - this.state.scrollY;
+        parsedElement.seed = randomSeed();
+        generateDraw(parsedElement);
+        elements.push(parsedElement);
+      });
+      this.forceUpdate();
+    }
+  };
+
   componentDidUpdate() {
     renderScene(elements, rc, canvas, {
       scrollX: this.state.scrollX,

+ 2 - 1
src/styles.scss

@@ -149,7 +149,8 @@ button {
     border-color: #d6d4d4;
   }
 
-  &:active, &.active {
+  &:active,
+  &.active {
     background-color: #bdbebc;
     border-color: #bdbebc;
   }