excalidraw/src/components/ExportDialog.tsx
Gasim Gasimzada 33016bf6bf
Fix issues related to history (#701)
* Separate UI from Canvas

* Explicitly define history recording

* ActionManager: Set syncActionState during construction instead of in every call

* Add commit to history flag to necessary actions

* Disable undoing during multiElement

* Write custom equality function for UI component to render it only when specific props and elements change

* Remove stale comments about history skipping

* Stop undo/redoing when in resizing element mode

* wip

* correctly reset resizingElement & add undo check

* Separate selection element from the rest of the array and stop redrawing the UI when dragging the selection

* Remove selectionElement from local storage

* Remove unnecessary readonly type casting in actionFinalize

* Fix undo / redo for multi points

* Fix an issue that did not update history when elements were locked

* Disable committing to history for noops

- deleteSelected without deleting anything
- Basic selection

* Use generateEntry only inside history and pass elements and appstate to history

* Update component after every history resume

* Remove last item from the history only if in multi mode

* Resume recording when element type is not selection

* ensure we prevent hotkeys only on writable elements

* Remove selection clearing from history

* Remove one point arrows as they are invisibly small

* Remove shape of elements from local storage

* Fix removing invisible element from the array

* add missing history resuming cases & simplify slice

* fix lint

* don't regenerate elements if no elements deselected

* regenerate elements array on selection

* reset state.selectionElement unconditionally

* Use getter instead of passing appState and scene data through functions to actions

* fix import

Co-authored-by: David Luzar <luzar.david@gmail.com>
2020-02-05 19:47:10 +01:00

260 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "./ExportDialog.css";
import React, { useState, useEffect, useRef } from "react";
import { Modal } from "./Modal";
import { ToolButton } from "./ToolButton";
import { clipboard, exportFile, link } from "./icons";
import { Island } from "./Island";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { exportToCanvas } from "../scene/export";
import { ActionsManagerInterface } from "../actions/types";
import Stack from "./Stack";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { probablySupportsClipboardBlob } from "../clipboard";
const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
type ExportCB = (
elements: readonly ExcalidrawElement[],
scale?: number,
) => void;
function ExportModal({
elements,
appState,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
onCloseRequest,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
onCloseRequest: () => void;
}) {
const someElementIsSelected = elements.some(element => element.isSelected);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const pngButton = useRef<HTMLButtonElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const onlySelectedInput = useRef<HTMLInputElement>(null);
const exportedElements = exportSelected
? elements.filter(element => element.isSelected)
: elements;
useEffect(() => {
setExportSelected(someElementIsSelected);
}, [someElementIsSelected]);
useEffect(() => {
const previewNode = previewRef.current;
const canvas = exportToCanvas(exportedElements, {
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
});
previewNode?.appendChild(canvas);
return () => {
previewNode?.removeChild(canvas);
};
}, [
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
scale,
]);
useEffect(() => {
pngButton.current?.focus();
}, []);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === KEYS.TAB) {
const { activeElement } = document;
if (e.shiftKey) {
if (activeElement === pngButton.current) {
closeButton.current?.focus();
e.preventDefault();
}
} else {
if (activeElement === closeButton.current) {
pngButton.current?.focus();
e.preventDefault();
}
if (activeElement === onlySelectedInput.current) {
closeButton.current?.focus();
e.preventDefault();
}
}
}
}
return (
<div className="ExportDialog__dialog" onKeyDown={handleKeyDown}>
<Island padding={4}>
<button
className="ExportDialog__close"
onClick={onCloseRequest}
aria-label={t("buttons.close")}
ref={closeButton}
>
</button>
<h2 id="export-title">{t("buttons.export")}</h2>
<div className="ExportDialog__preview" ref={previewRef}></div>
<div className="ExportDialog__actions">
<Stack.Col gap={1}>
<Stack.Row gap={2}>
<ToolButton
type="button"
label="PNG"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements, scale)}
ref={pngButton}
/>
<ToolButton
type="button"
label="SVG"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements, scale)}
/>
{probablySupportsClipboardBlob && (
<ToolButton
type="button"
icon={clipboard}
title={t("buttons.copyToClipboard")}
aria-label={t("buttons.copyToClipboard")}
onClick={() => onExportToClipboard(exportedElements, scale)}
/>
)}
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
</Stack.Row>
</Stack.Col>
{actionManager.renderAction("changeProjectName")}
<Stack.Col gap={1}>
<div className="ExportDialog__scales">
<Stack.Row gap={2} align="baseline">
{scales.map(s => (
<ToolButton
key={s}
size="s"
type="radio"
icon={`x${s}`}
name="export-canvas-scale"
aria-label={`Scale ${s} x`}
id="export-canvas-scale"
checked={scale === s}
onChange={() => setScale(s)}
/>
))}
</Stack.Row>
</div>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<div>
<label>
<input
type="checkbox"
checked={exportSelected}
onChange={e => setExportSelected(e.currentTarget.checked)}
ref={onlySelectedInput}
/>{" "}
{t("labels.onlySelected")}
</label>
</div>
)}
</Stack.Col>
</div>
</Island>
</div>
);
}
export function ExportDialog({
elements,
appState,
exportPadding = 10,
actionManager,
onExportToPng,
onExportToSvg,
onExportToClipboard,
onExportToBackend,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
}) {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
return (
<>
<ToolButton
onClick={() => setModalIsShown(true)}
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
title={t("buttons.export")}
ref={triggerButton}
/>
{modalIsShown && (
<Modal
maxWidth={800}
onCloseRequest={handleClose}
labelledBy="export-title"
>
<ExportModal
elements={elements}
appState={appState}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
/>
</Modal>
)}
</>
);
}