move footer into layerUI & refactor ActionManager (#729)

This commit is contained in:
David Luzar 2020-02-07 23:46:19 +01:00 committed by GitHub
parent 88eacc9da7
commit d79293de06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 164 additions and 179 deletions

View File

@ -14,20 +14,16 @@ export class ActionManager implements ActionsManagerInterface {
updater: UpdaterFn; updater: UpdaterFn;
resumeHistoryRecording: () => void;
getAppState: () => AppState; getAppState: () => AppState;
getElements: () => readonly ExcalidrawElement[]; getElements: () => readonly ExcalidrawElement[];
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
resumeHistoryRecording: () => void,
getAppState: () => AppState, getAppState: () => AppState,
getElements: () => readonly ExcalidrawElement[], getElements: () => readonly ExcalidrawElement[],
) { ) {
this.updater = updater; this.updater = updater;
this.resumeHistoryRecording = resumeHistoryRecording;
this.getAppState = getAppState; this.getAppState = getAppState;
this.getElements = getElements; this.getElements = getElements;
} }
@ -46,17 +42,18 @@ export class ActionManager implements ActionsManagerInterface {
); );
if (data.length === 0) { if (data.length === 0) {
return null; return false;
} }
event.preventDefault(); event.preventDefault();
if ( const commitToHistory =
data[0].commitToHistory && data[0].commitToHistory &&
data[0].commitToHistory(this.getAppState(), this.getElements()) data[0].commitToHistory(this.getAppState(), this.getElements());
) { this.updater(
this.resumeHistoryRecording(); data[0].perform(this.getElements(), this.getAppState(), null),
} commitToHistory,
return data[0].perform(this.getElements(), this.getAppState(), null); );
return true;
} }
getContextMenuItems(actionFilter: ActionFilterFn = action => action) { getContextMenuItems(actionFilter: ActionFilterFn = action => action) {
@ -71,14 +68,12 @@ export class ActionManager implements ActionsManagerInterface {
.map(action => ({ .map(action => ({
label: action.contextItemLabel ? t(action.contextItemLabel) : "", label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => { action: () => {
if ( const commitToHistory =
action.commitToHistory && action.commitToHistory &&
action.commitToHistory(this.getAppState(), this.getElements()) action.commitToHistory(this.getAppState(), this.getElements());
) {
this.resumeHistoryRecording();
}
this.updater( this.updater(
action.perform(this.getElements(), this.getAppState(), null), action.perform(this.getElements(), this.getAppState(), null),
commitToHistory,
); );
}, },
})); }));
@ -89,15 +84,12 @@ export class ActionManager implements ActionsManagerInterface {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
const updateData = (formState: any) => { const updateData = (formState: any) => {
if ( const commitToHistory =
action.commitToHistory && action.commitToHistory &&
action.commitToHistory(this.getAppState(), this.getElements()) === action.commitToHistory(this.getAppState(), this.getElements());
true
) {
this.resumeHistoryRecording();
}
this.updater( this.updater(
action.perform(this.getElements(), this.getAppState(), formState), action.perform(this.getElements(), this.getAppState(), formState),
commitToHistory,
); );
}; };

View File

@ -3,8 +3,8 @@ import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
export type ActionResult = { export type ActionResult = {
elements?: readonly ExcalidrawElement[]; elements?: readonly ExcalidrawElement[] | null;
appState?: AppState; appState?: AppState | null;
}; };
type ActionFn = ( type ActionFn = (
@ -13,7 +13,7 @@ type ActionFn = (
formData: any, formData: any,
) => ActionResult; ) => ActionResult;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export interface Action { export interface Action {
@ -43,7 +43,7 @@ export interface ActionsManagerInterface {
[keyProp: string]: Action; [keyProp: string]: Action;
}; };
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => ActionResult | null; handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: ( getContextMenuItems: (
actionFilter: ActionFilterFn, actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[]; ) => { label: string; action: () => void }[];

View File

@ -1,6 +1,5 @@
import { AppState } from "./types"; import { AppState } from "./types";
import { getDateTime } from "./utils"; import { getDateTime } from "./utils";
import { getLanguage } from "./i18n";
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
@ -29,7 +28,6 @@ export function getDefaultAppState(): AppState {
name: DEFAULT_PROJECT_NAME, name: DEFAULT_PROJECT_NAME,
isResizing: false, isResizing: false,
selectionElement: null, selectionElement: null,
lng: getLanguage(),
}; };
} }

View File

@ -23,7 +23,6 @@ import {
deleteSelectedElements, deleteSelectedElements,
getElementsWithinSelection, getElementsWithinSelection,
isOverScrollBars, isOverScrollBars,
restoreFromLocalStorage,
saveToLocalStorage, saveToLocalStorage,
getElementAtPosition, getElementAtPosition,
createScene, createScene,
@ -32,9 +31,8 @@ import {
hasStroke, hasStroke,
hasText, hasText,
exportCanvas, exportCanvas,
importFromBackend,
addToLoadedScenes,
loadedScenes, loadedScenes,
loadScene,
calculateScrollCenter, calculateScrollCenter,
loadFromBlob, loadFromBlob,
} from "./scene"; } from "./scene";
@ -163,6 +161,7 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: any; setAppState: any;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
language: string;
setElements: (elements: readonly ExcalidrawElement[]) => void; setElements: (elements: readonly ExcalidrawElement[]) => void;
} }
@ -173,6 +172,7 @@ const LayerUI = React.memo(
setAppState, setAppState,
canvas, canvas,
elements, elements,
language,
setElements, setElements,
}: LayerUIProps) => { }: LayerUIProps) => {
function renderCanvasActions() { function renderCanvasActions() {
@ -318,56 +318,101 @@ const LayerUI = React.memo(
); );
} }
function renderIdsDropdown() {
const scenes = loadedScenes();
if (scenes.length === 0) {
return;
}
return (
<StoredScenesList
scenes={scenes}
currentId={appState.selectedId}
onChange={async (id, k) =>
actionManager.updater(await loadScene(id, k))
}
/>
);
}
return ( return (
<FixedSideContainer side="top"> <>
<div className="App-menu App-menu_top"> <FixedSideContainer side="top">
<Stack.Col gap={4} align="end"> <div className="App-menu App-menu_top">
<section <Stack.Col gap={4} align="end">
className="App-right-menu" <section
aria-labelledby="canvas-actions-title" className="App-right-menu"
> aria-labelledby="canvas-actions-title"
<h2 className="visually-hidden" id="canvas-actions-title"> >
{t("headings.canvasActions")} <h2 className="visually-hidden" id="canvas-actions-title">
</h2> {t("headings.canvasActions")}
<Island padding={4}>{renderCanvasActions()}</Island> </h2>
</section> <Island padding={4}>{renderCanvasActions()}</Island>
<section </section>
className="App-right-menu" <section
aria-labelledby="selected-shape-title" className="App-right-menu"
> aria-labelledby="selected-shape-title"
<h2 className="visually-hidden" id="selected-shape-title"> >
{t("headings.selectedShapeActions")} <h2 className="visually-hidden" id="selected-shape-title">
</h2> {t("headings.selectedShapeActions")}
{renderSelectedShapeActions(elements)} </h2>
</section> {renderSelectedShapeActions(elements)}
</Stack.Col> </section>
<section aria-labelledby="shapes-title">
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island padding={1}>
<h2 className="visually-hidden" id="shapes-title">
{t("headings.shapes")}
</h2>
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={() => {
setAppState({
elementLocked: !appState.elementLocked,
elementType: appState.elementLocked
? "selection"
: appState.elementType,
});
}}
title={t("toolBar.lock")}
/>
</Stack.Row>
</Stack.Col> </Stack.Col>
</section> <section aria-labelledby="shapes-title">
<div /> <Stack.Col gap={4} align="start">
</div> <Stack.Row gap={1}>
</FixedSideContainer> <Island padding={1}>
<h2 className="visually-hidden" id="shapes-title">
{t("headings.shapes")}
</h2>
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={() => {
setAppState({
elementLocked: !appState.elementLocked,
elementType: appState.elementLocked
? "selection"
: appState.elementType,
});
}}
title={t("toolBar.lock")}
/>
</Stack.Row>
</Stack.Col>
</section>
<div />
</div>
</FixedSideContainer>
<footer role="contentinfo">
<HintViewer
elementType={appState.elementType}
multiMode={appState.multiElement !== null}
isResizing={appState.isResizing}
elements={elements}
/>
<LanguageList
onChange={lng => {
setLanguage(lng);
setAppState({});
}}
languages={languages}
currentLanguage={language}
/>
{renderIdsDropdown()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({ ...calculateScrollCenter(elements) });
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</>
); );
}, },
(prev, next) => { (prev, next) => {
@ -390,6 +435,7 @@ const LayerUI = React.memo(
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[]; const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return ( return (
prev.language === next.language &&
prev.elements === next.elements && prev.elements === next.elements &&
keys.every(k => prevAppState[k] === nextAppState[k]) keys.every(k => prevAppState[k] === nextAppState[k])
); );
@ -406,9 +452,6 @@ export class App extends React.Component<any, AppState> {
super(props); super(props);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
() => {
history.resumeRecording();
},
() => this.state, () => this.state,
() => elements, () => elements,
); );
@ -443,13 +486,22 @@ export class App extends React.Component<any, AppState> {
this.canvasOnlyActions = [actionSelectAll]; this.canvasOnlyActions = [actionSelectAll];
} }
private syncActionResult = (res: ActionResult) => { private syncActionResult = (
if (res.elements !== undefined) { res: ActionResult,
commitToHistory: boolean = true,
) => {
if (res.elements) {
elements = res.elements; elements = res.elements;
if (commitToHistory) {
history.resumeRecording();
}
this.setState({}); this.setState({});
} }
if (res.appState !== undefined) { if (res.appState) {
if (commitToHistory) {
history.resumeRecording();
}
this.setState({ ...res.appState }); this.setState({ ...res.appState });
} }
}; };
@ -478,32 +530,6 @@ export class App extends React.Component<any, AppState> {
this.saveDebounced.flush(); this.saveDebounced.flush();
}; };
private async loadScene(id: string | null, k: string | undefined) {
let data;
let selectedId;
if (id != null) {
// k is the private key used to decrypt the content from the server, take
// extra care not to leak it
data = await importFromBackend(id, k);
addToLoadedScenes(id, k);
selectedId = id;
window.history.replaceState({}, "Excalidraw", window.location.origin);
} else {
data = restoreFromLocalStorage();
}
if (data.elements) {
elements = data.elements;
}
if (data.appState) {
history.resumeRecording();
this.setState({ ...data.appState, selectedId });
} else {
this.setState({});
}
}
public async componentDidMount() { public async componentDidMount() {
document.addEventListener("copy", this.onCopy); document.addEventListener("copy", this.onCopy);
document.addEventListener("paste", this.pasteFromClipboard); document.addEventListener("paste", this.pasteFromClipboard);
@ -523,15 +549,15 @@ export class App extends React.Component<any, AppState> {
if (id) { if (id) {
// Backwards compatibility with legacy url format // Backwards compatibility with legacy url format
this.loadScene(id, undefined); this.syncActionResult(await loadScene(id));
} else { } else {
const match = window.location.hash.match( const match = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
); );
if (match) { if (match) {
this.loadScene(match[1], match[2]); this.syncActionResult(await loadScene(match[1], match[2]));
} else { } else {
this.loadScene(null, undefined); this.syncActionResult(await loadScene(null));
} }
} }
} }
@ -572,13 +598,8 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
const actionResult = this.actionManager.handleKeyDown(event); if (this.actionManager.handleKeyDown(event)) {
return;
if (actionResult) {
this.syncActionResult(actionResult);
if (actionResult) {
return;
}
} }
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key);
@ -750,6 +771,7 @@ export class App extends React.Component<any, AppState> {
actionManager={this.actionManager} actionManager={this.actionManager}
elements={elements} elements={elements}
setElements={this.setElements} setElements={this.setElements}
language={getLanguage()}
/> />
<main> <main>
<canvas <canvas
@ -1797,10 +1819,7 @@ export class App extends React.Component<any, AppState> {
if (file?.type === "application/json") { if (file?.type === "application/json") {
loadFromBlob(file) loadFromBlob(file)
.then(({ elements, appState }) => .then(({ elements, appState }) =>
this.syncActionResult({ this.syncActionResult({ elements, appState }),
elements,
appState,
} as ActionResult),
) )
.catch(err => console.error(err)); .catch(err => console.error(err));
} }
@ -1809,52 +1828,10 @@ export class App extends React.Component<any, AppState> {
{t("labels.drawingCanvas")} {t("labels.drawingCanvas")}
</canvas> </canvas>
</main> </main>
<footer role="contentinfo">
<HintViewer
elementType={this.state.elementType}
multiMode={this.state.multiElement !== null}
isResizing={this.state.isResizing}
elements={elements}
/>
<LanguageList
onChange={lng => {
setLanguage(lng);
this.setState({ lng });
}}
languages={languages}
currentLanguage={getLanguage()}
/>
{this.renderIdsDropdown()}
{this.state.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
this.setState({ ...calculateScrollCenter(elements) });
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</div> </div>
); );
} }
private renderIdsDropdown() {
const scenes = loadedScenes();
if (scenes.length === 0) {
return;
}
return (
<StoredScenesList
scenes={scenes}
currentId={this.state.selectedId}
onChange={(id, k) => this.loadScene(id, k)}
/>
);
}
private handleWheel = (e: WheelEvent) => { private handleWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
const { deltaX, deltaY } = e; const { deltaX, deltaY } = e;

View File

@ -123,17 +123,15 @@ export async function loadFromBlob(blob: any) {
if ("text" in Blob) { if ("text" in Blob) {
contents = await blob.text(); contents = await blob.text();
} else { } else {
contents = await (async () => { contents = await new Promise(resolve => {
return new Promise(resolve => { const reader = new FileReader();
const reader = new FileReader(); reader.readAsText(blob, "utf8");
reader.readAsText(blob, "utf8"); reader.onloadend = () => {
reader.onloadend = () => { if (reader.readyState === FileReader.DONE) {
if (reader.readyState === FileReader.DONE) { resolve(reader.result as string);
resolve(reader.result as string); }
} };
}; });
});
})();
} }
const { elements, appState } = updateAppState(contents); const { elements, appState } = updateAppState(contents);
if (!elements.length) { if (!elements.length) {
@ -488,3 +486,23 @@ export function addToLoadedScenes(id: string, k: string | undefined): void {
JSON.stringify(scenes), JSON.stringify(scenes),
); );
} }
export async function loadScene(id: string | null, k?: string) {
let data;
let selectedId;
if (id != null) {
// k is the private key used to decrypt the content from the server, take
// extra care not to leak it
data = await importFromBackend(id, k);
addToLoadedScenes(id, k);
selectedId = id;
window.history.replaceState({}, "Excalidraw", window.location.origin);
} else {
data = restoreFromLocalStorage();
}
return {
elements: data.elements,
appState: data.appState && { ...data.appState, selectedId },
};
}

View File

@ -18,6 +18,7 @@ export {
importFromBackend, importFromBackend,
addToLoadedScenes, addToLoadedScenes,
loadedScenes, loadedScenes,
loadScene,
calculateScrollCenter, calculateScrollCenter,
} from "./data"; } from "./data";
export { export {

View File

@ -28,5 +28,4 @@ export type AppState = {
name: string; name: string;
selectedId?: string; selectedId?: string;
isResizing: boolean; isResizing: boolean;
lng: string;
}; };