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

This commit is contained in:
Robinson Marquez 2020-01-30 16:39:37 -03:00 committed by GitHub
parent bd1c00014b
commit 4ad38e317e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1461 additions and 795 deletions

2016
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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?",

View File

@ -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?",

View File

@ -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 ?",

View File

@ -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?",

View File

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

View File

@ -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);
});
});
});

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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),
);
}

View File

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

View File

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

View File

@ -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;

View File

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