1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-02 03:25:53 +01:00

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

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

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

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

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

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

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

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

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

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

@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { AppState } from "../types"; import { AppState } from "../types";
import { ExportType } from "./types"; import { ExportType, PreviousScene } from "./types";
import { exportToCanvas, exportToSvg } from "./export"; import { exportToCanvas, exportToSvg } from "./export";
import nanoid from "nanoid"; import nanoid from "nanoid";
import { fileOpen, fileSave } from "browser-nativefs"; import { fileOpen, fileSave } from "browser-nativefs";
@ -12,6 +12,7 @@ import { getCommonBounds } from "../element";
import i18n from "../i18n"; import i18n from "../i18n";
const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY = "excalidraw";
const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/"; const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/";
const BACKEND_GET = "https://json.excalidraw.com/api/v1/"; const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
@ -24,6 +25,7 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
interface DataState { interface DataState {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
appState: AppState; appState: AppState;
selectedId?: number;
} }
export function serializeAsJSON( export function serializeAsJSON(
@ -305,3 +307,43 @@ export function saveToLocalStorage(
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements)); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state)); 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),
);
}

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

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

@ -213,13 +213,13 @@ button,
} }
} }
.language-select { .dropdown-select {
position: absolute; position: absolute;
margin-bottom: 0.5em;
margin-right: 0.5em;
height: 1.5rem;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: 1.5rem;
margin-bottom: 0.5rem;
margin-right: 0.5rem;
padding: 0 1.5rem 0 0.5rem; padding: 0 1.5rem 0 0.5rem;
background-color: #e9ecef; background-color: #e9ecef;
border-radius: var(--space-factor); 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 { .visually-hidden {
position: absolute !important; position: absolute !important;
height: 1px; height: 1px;

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