Browse Source

485: Ability to switch to previously loaded ids in UI (#583)

Robinson Marquez 5 years ago
parent
commit
4ad38e317e

File diff suppressed because it is too large
+ 408 - 371
package-lock.json


+ 4 - 0
package.json

@@ -19,11 +19,15 @@
   },
   "description": "",
   "devDependencies": {
+    "@types/enzyme": "3.10.4",
+    "@types/enzyme-adapter-react-16": "1.0.5",
     "@types/jest": "25.1.0",
     "@types/nanoid": "2.1.0",
     "@types/react": "16.9.19",
     "@types/react-color": "3.0.1",
     "@types/react-dom": "16.9.5",
+    "enzyme": "3.11.0",
+    "enzyme-adapter-react-16": "1.15.2",
     "husky": "4.2.1",
     "lint-staged": "10.0.3",
     "node-sass": "4.13.1",

+ 2 - 1
public/locales/en/translation.json

@@ -51,7 +51,8 @@
     "load": "Load",
     "getShareableLink": "Get shareable link",
     "close": "Close",
-    "selectLanguage": "Select Language"
+    "selectLanguage": "Select Language",
+    "previouslyLoadedScenes": "Previously loaded scenes"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 2 - 1
public/locales/es/translation.json

@@ -52,7 +52,8 @@
     "getShareableLink": "Obtener enlace para compartir",
     "showExportDialog": "Mostrar diálogo para exportar",
     "close": "Cerrar",
-    "selectLanguage": "Seleccionar idioma"
+    "selectLanguage": "Seleccionar idioma",
+    "previouslyLoadedScenes": "Escenas previamente cargadas"
   },
   "alerts": {
     "clearReset": "Esto limpiará todo el lienzo. Estás seguro?",

+ 2 - 1
public/locales/fr/translation.json

@@ -45,7 +45,8 @@
     "copyToClipboard": "Copier dans le presse-papier",
     "save": "Sauvegarder",
     "load": "Ouvrir",
-    "getShareableLink": "Obtenir un lien de partage"
+    "getShareableLink": "Obtenir un lien de partage",
+    "previouslyLoadedScenes": "Scènes précédemment chargées"
   },
   "alerts": {
     "clearReset": "L'intégralité du canvas va être effacé. Êtes-vous sur ?",

+ 2 - 1
public/locales/pt/translation.json

@@ -45,7 +45,8 @@
     "copyToClipboard": "Copiar para o clipboard",
     "save": "Guardar",
     "load": "Carregar",
-    "getShareableLink": "Obter um link de partilha"
+    "getShareableLink": "Obter um link de partilha",
+    "previouslyLoadedScenes": "Cenas carregadas anteriormente"
   },
   "alerts": {
     "clearReset": "O canvas inteiro será excluído. Tens a certeza?",

+ 2 - 1
public/locales/ru/translation.json

@@ -51,7 +51,8 @@
     "load": "Загрузить",
     "getShareableLink": "Получить доступ по ссылке",
     "close": "Закрыть",
-    "selectLanguage": "Выбрать язык"
+    "selectLanguage": "Выбрать язык",
+    "previouslyLoadedScenes": "Ранее загруженные сцены"
   },
   "alerts": {
     "clearReset": "Это очистит весь холст. Вы уверены?",

+ 56 - 0
src/components/StoredScenesList.test.tsx

@@ -0,0 +1,56 @@
+import React from "react";
+import Enzyme, { shallow } from "enzyme";
+import Adapter from "enzyme-adapter-react-16";
+import { StoredScenesList } from "./StoredScenesList";
+import { PreviousScene } from "../scene/types";
+
+Enzyme.configure({ adapter: new Adapter() });
+
+jest.mock("react-i18next", () => ({
+  useTranslation: () => ({ t: (key: any) => key }),
+}));
+
+function setup(props: any) {
+  const currentProps = {
+    ...props,
+    onChange: jest.fn(),
+  };
+  return {
+    wrapper: shallow(<StoredScenesList {...currentProps} />),
+    props: currentProps,
+  };
+}
+
+describe("<StoredScenesList/>", () => {
+  const scenes: PreviousScene[] = [
+    {
+      id: "123",
+      timestamp: Date.now(),
+    },
+    {
+      id: "234",
+      timestamp: Date.now(),
+    },
+    {
+      id: "345",
+      timestamp: Date.now(),
+    },
+  ];
+
+  const { wrapper, props } = setup({ scenes });
+
+  describe("Renders the ids correctly when", () => {
+    it("select options and ids length are the same", () => {
+      expect(wrapper.find("option").length).toBe(scenes.length);
+    });
+  });
+
+  describe("Can handle id selection when", () => {
+    it("onChange method is called when select option has changed", async () => {
+      const select = wrapper.find("select") as any;
+      const mockedEvenet = { currentTarget: { value: "1" } };
+      await select.invoke("onChange")(mockedEvenet);
+      expect(props.onChange.mock.calls.length).toBe(1);
+    });
+  });
+});

+ 34 - 0
src/components/StoredScenesList.tsx

@@ -0,0 +1,34 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { PreviousScene } from "../scene/types";
+
+interface StoredScenesListProps {
+  scenes: PreviousScene[];
+  currentId?: string;
+  onChange: (selectedId: string) => {};
+}
+
+export function StoredScenesList({
+  scenes,
+  currentId,
+  onChange,
+}: StoredScenesListProps) {
+  const { t } = useTranslation();
+
+  return (
+    <React.Fragment>
+      <select
+        className="stored-ids-select"
+        onChange={({ currentTarget }) => onChange(currentTarget.value)}
+        value={currentId}
+        title={t("buttons.previouslyLoadedScenes")}
+      >
+        {scenes.map(scene => (
+          <option key={scene.id} value={scene.id}>
+            id={scene.id}
+          </option>
+        ))}
+      </select>
+    </React.Fragment>
+  );
+}

+ 41 - 15
src/index.tsx

@@ -34,6 +34,8 @@ import {
   hasText,
   exportCanvas,
   importFromBackend,
+  addToLoadedScenes,
+  loadedScenes,
 } from "./scene";
 
 import { renderScene } from "./renderer";
@@ -86,6 +88,7 @@ import { ExportDialog } from "./components/ExportDialog";
 import { withTranslation } from "react-i18next";
 import { LanguageList } from "./components/LanguageList";
 import i18n, { languages, parseDetectedLang } from "./i18n";
+import { StoredScenesList } from "./components/StoredScenesList";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -237,21 +240,13 @@ export class App extends React.Component<any, AppState> {
     return true;
   }
 
-  public async componentDidMount() {
-    document.addEventListener("copy", this.onCopy);
-    document.addEventListener("paste", this.onPaste);
-    document.addEventListener("cut", this.onCut);
-
-    document.addEventListener("keydown", this.onKeyDown, false);
-    document.addEventListener("mousemove", this.updateCurrentCursorPosition);
-    window.addEventListener("resize", this.onResize, false);
-    window.addEventListener("unload", this.onUnload, false);
-
+  private async loadScene(id: string | null) {
     let data;
-    const searchParams = new URLSearchParams(window.location.search);
-
-    if (searchParams.get("id") != null) {
-      data = await importFromBackend(searchParams.get("id"));
+    let selectedId;
+    if (id != null) {
+      data = await importFromBackend(id);
+      addToLoadedScenes(id);
+      selectedId = id;
       window.history.replaceState({}, "Excalidraw", window.location.origin);
     } else {
       data = restoreFromLocalStorage();
@@ -262,12 +257,28 @@ export class App extends React.Component<any, AppState> {
     }
 
     if (data.appState) {
-      this.setState(data.appState);
+      this.setState({ ...data.appState, selectedId });
     } else {
       this.setState({});
     }
   }
 
+  public async componentDidMount() {
+    document.addEventListener("copy", this.onCopy);
+    document.addEventListener("paste", this.onPaste);
+    document.addEventListener("cut", this.onCut);
+
+    document.addEventListener("keydown", this.onKeyDown, false);
+    document.addEventListener("mousemove", this.updateCurrentCursorPosition);
+    window.addEventListener("resize", this.onResize, false);
+    window.addEventListener("unload", this.onUnload, false);
+
+    const searchParams = new URLSearchParams(window.location.search);
+    const id = searchParams.get("id");
+
+    this.loadScene(id);
+  }
+
   public componentWillUnmount() {
     document.removeEventListener("copy", this.onCopy);
     document.removeEventListener("paste", this.onPaste);
@@ -1420,11 +1431,26 @@ export class App extends React.Component<any, AppState> {
             languages={languages}
             currentLanguage={parseDetectedLang(i18n.language)}
           />
+          {this.renderIdsDropdown()}
         </footer>
       </div>
     );
   }
 
+  private renderIdsDropdown() {
+    const scenes = loadedScenes();
+    if (scenes.length === 0) {
+      return;
+    }
+    return (
+      <StoredScenesList
+        scenes={scenes}
+        currentId={this.state.selectedId}
+        onChange={id => this.loadScene(id)}
+      />
+    );
+  }
+
   private handleWheel = (e: WheelEvent) => {
     e.preventDefault();
     const { deltaX, deltaY } = e;

+ 43 - 1
src/scene/data.ts

@@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
 import { getDefaultAppState } from "../appState";
 
 import { AppState } from "../types";
-import { ExportType } from "./types";
+import { ExportType, PreviousScene } from "./types";
 import { exportToCanvas, exportToSvg } from "./export";
 import nanoid from "nanoid";
 import { fileOpen, fileSave } from "browser-nativefs";
@@ -12,6 +12,7 @@ import { getCommonBounds } from "../element";
 import i18n from "../i18n";
 
 const LOCAL_STORAGE_KEY = "excalidraw";
+const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
 const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
 const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/";
 const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
@@ -24,6 +25,7 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
 interface DataState {
   elements: readonly ExcalidrawElement[];
   appState: AppState;
+  selectedId?: number;
 }
 
 export function serializeAsJSON(
@@ -305,3 +307,43 @@ export function saveToLocalStorage(
   localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
   localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state));
 }
+
+/**
+ * Returns the list of ids in Local Storage
+ * @returns array
+ */
+export function loadedScenes(): PreviousScene[] {
+  const storedPreviousScenes = localStorage.getItem(
+    LOCAL_STORAGE_SCENE_PREVIOUS_KEY,
+  );
+  if (storedPreviousScenes) {
+    try {
+      return JSON.parse(storedPreviousScenes);
+    } catch (e) {
+      console.error("Could not parse previously stored ids");
+      return [];
+    }
+  }
+  return [];
+}
+
+/**
+ * Append id to the list of Previous Scenes in Local Storage if not there yet
+ * @param id string
+ */
+export function addToLoadedScenes(id: string): void {
+  const scenes = [...loadedScenes()];
+  const newScene = scenes.every(scene => scene.id !== id);
+
+  if (newScene) {
+    scenes.push({
+      timestamp: Date.now(),
+      id,
+    });
+  }
+
+  localStorage.setItem(
+    LOCAL_STORAGE_SCENE_PREVIOUS_KEY,
+    JSON.stringify(scenes),
+  );
+}

+ 2 - 0
src/scene/index.ts

@@ -15,6 +15,8 @@ export {
   saveToLocalStorage,
   exportToBackend,
   importFromBackend,
+  addToLoadedScenes,
+  loadedScenes,
 } from "./data";
 export {
   hasBackground,

+ 5 - 0
src/scene/types.ts

@@ -16,4 +16,9 @@ export interface Scene {
   elements: ExcalidrawTextElement[];
 }
 
+export interface PreviousScene {
+  id: string;
+  timestamp: number;
+}
+
 export type ExportType = "png" | "clipboard" | "backend" | "svg";

+ 19 - 4
src/styles.scss

@@ -213,13 +213,13 @@ button,
   }
 }
 
-.language-select {
+.dropdown-select {
   position: absolute;
+  margin-bottom: 0.5em;
+  margin-right: 0.5em;
+  height: 1.5rem;
   right: 0;
   bottom: 0;
-  height: 1.5rem;
-  margin-bottom: 0.5rem;
-  margin-right: 0.5rem;
   padding: 0 1.5rem 0 0.5rem;
   background-color: #e9ecef;
   border-radius: var(--space-factor);
@@ -245,6 +245,21 @@ button,
   }
 }
 
+.language-select {
+  @extend .dropdown-select;
+  right: 0;
+  bottom: 0;
+}
+
+.stored-ids-select {
+  @extend .dropdown-select;
+  padding: 0 0.5em 0 1.7em;
+  bottom: 0;
+  left: 0;
+  background-position: left 0.7em top 50%, 0 0;
+  margin-left: 0.5em;
+}
+
 .visually-hidden {
   position: absolute !important;
   height: 1px;

+ 1 - 0
src/types.ts

@@ -22,4 +22,5 @@ export type AppState = {
   cursorX: number;
   cursorY: number;
   name: string;
+  selectedId?: string;
 };

Some files were not shown because too many files changed in this diff