mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
Add Native File System API saving/exporting and opening (#388)
* Add Native File System API saving/exporting * Add Native File System API opening * Add origin trial token placeholder * Reuse an opened file handle for better saving experience * Fix file handle reuse to only kick in for Excalidraw files * Remove reference
This commit is contained in:
parent
f4d4b323e1
commit
7ddc206b8c
@ -8,6 +8,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta http-equiv="origin-trial" content="AvB+3K1LqGdVR+XcHhjpdM0yl5/RtR/v7MIO/nbgNnLZHJ5yMYos9kZAQiuc0EEZne4d9CzHhF2sk2fUPOylcgUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4Mjg3ODU4NH0=">
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
|
@ -39,6 +39,10 @@ export const actionClearCanvas: Action = {
|
||||
aria-label="Clear the canvas & reset background color"
|
||||
onClick={() => {
|
||||
if (window.confirm("This will clear the whole canvas. Are you sure?")) {
|
||||
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||
// Once `FileSystemFileHandle` can be serialized, make this
|
||||
// part of `AppState`.
|
||||
(window as any).handle = null;
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
|
@ -40,7 +40,7 @@ export const actionChangeExportBackground: Action = {
|
||||
export const actionSaveScene: Action = {
|
||||
name: "saveScene",
|
||||
perform: (elements, appState, value) => {
|
||||
saveAsJSON(elements, appState);
|
||||
saveAsJSON(elements, appState).catch(err => console.error(err));
|
||||
return {};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
@ -70,9 +70,11 @@ export const actionLoadScene: Action = {
|
||||
title="Load"
|
||||
aria-label="Load"
|
||||
onClick={() => {
|
||||
loadFromJSON().then(({ elements, appState }) => {
|
||||
updateData({ elements: elements, appState: appState });
|
||||
});
|
||||
loadFromJSON()
|
||||
.then(({ elements, appState }) => {
|
||||
updateData({ elements: elements, appState: appState });
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
@ -13,6 +13,11 @@ import nanoid from "nanoid";
|
||||
const LOCAL_STORAGE_KEY = "excalidraw";
|
||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
||||
|
||||
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||
// Once `FileSystemFileHandle` can be serialized, make this
|
||||
// part of `AppState`.
|
||||
(window as any).handle = null;
|
||||
|
||||
function saveFile(name: string, data: string) {
|
||||
// create a temporary <a> elem which we'll use to download the image
|
||||
const link = document.createElement("a");
|
||||
@ -24,12 +29,54 @@ function saveFile(name: string, data: string) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
async function saveFileNative(name: string, data: Blob) {
|
||||
const options = {
|
||||
type: "saveFile",
|
||||
accepts: [
|
||||
{
|
||||
description: `Excalidraw ${
|
||||
data.type === "image/png" ? "image" : "file"
|
||||
}`,
|
||||
extensions: [data.type.split("/")[1]],
|
||||
mimeTypes: [data.type]
|
||||
}
|
||||
]
|
||||
};
|
||||
try {
|
||||
let handle;
|
||||
if (data.type === "application/json") {
|
||||
// For Excalidraw files (i.e., `application/json` files):
|
||||
// If it exists, write back to a previously opened file.
|
||||
// Else, create a new file.
|
||||
if ((window as any).handle) {
|
||||
handle = (window as any).handle;
|
||||
} else {
|
||||
handle = await (window as any).chooseFileSystemEntries(options);
|
||||
(window as any).handle = handle;
|
||||
}
|
||||
} else {
|
||||
// For image export files (i.e., `image/png` files):
|
||||
// Always create a new file.
|
||||
handle = await (window as any).chooseFileSystemEntries(options);
|
||||
}
|
||||
const writer = await handle.createWriter();
|
||||
await writer.truncate(0);
|
||||
await writer.write(0, data, data.type);
|
||||
await writer.close();
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error(err.name, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
interface DataState {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
export function saveAsJSON(
|
||||
export async function saveAsJSON(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState
|
||||
) {
|
||||
@ -40,46 +87,86 @@ export function saveAsJSON(
|
||||
appState: appState
|
||||
});
|
||||
|
||||
saveFile(
|
||||
`${appState.name}.json`,
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(serialized)
|
||||
);
|
||||
const name = `${appState.name}.json`;
|
||||
if ("chooseFileSystemEntries" in window) {
|
||||
await saveFileNative(
|
||||
name,
|
||||
new Blob([serialized], { type: "application/json" })
|
||||
);
|
||||
} else {
|
||||
saveFile(
|
||||
name,
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(serialized)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadFromJSON() {
|
||||
const input = document.createElement("input");
|
||||
const reader = new FileReader();
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
|
||||
input.onchange = () => {
|
||||
if (!input.files!.length) {
|
||||
alert("A file was not selected.");
|
||||
return;
|
||||
export async function loadFromJSON() {
|
||||
const updateAppState = (contents: string) => {
|
||||
const defaultAppState = getDefaultAppState();
|
||||
let elements = [];
|
||||
let appState = defaultAppState;
|
||||
try {
|
||||
const data = JSON.parse(contents);
|
||||
elements = data.elements || [];
|
||||
appState = { ...defaultAppState, ...data.appState };
|
||||
} catch (e) {
|
||||
// Do nothing because elements array is already empty
|
||||
}
|
||||
|
||||
reader.readAsText(input.files![0], "utf8");
|
||||
return { elements, appState };
|
||||
};
|
||||
|
||||
input.click();
|
||||
|
||||
return new Promise<DataState>(resolve => {
|
||||
reader.onloadend = () => {
|
||||
if (reader.readyState === FileReader.DONE) {
|
||||
const defaultAppState = getDefaultAppState();
|
||||
let elements = [];
|
||||
let appState = defaultAppState;
|
||||
try {
|
||||
const data = JSON.parse(reader.result as string);
|
||||
elements = data.elements || [];
|
||||
appState = { ...defaultAppState, ...data.appState };
|
||||
} catch (e) {
|
||||
// Do nothing because elements array is already empty
|
||||
}
|
||||
if ("chooseFileSystemEntries" in window) {
|
||||
try {
|
||||
(window as any).handle = await (window as any).chooseFileSystemEntries({
|
||||
accepts: [
|
||||
{
|
||||
description: "Excalidraw files",
|
||||
extensions: ["json"],
|
||||
mimeTypes: ["application/json"]
|
||||
}
|
||||
]
|
||||
});
|
||||
const file = await (window as any).handle.getFile();
|
||||
const contents = await file.text();
|
||||
const { elements, appState } = updateAppState(contents);
|
||||
return new Promise<DataState>(resolve => {
|
||||
resolve(restore(elements, appState));
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error(err.name, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
const input = document.createElement("input");
|
||||
const reader = new FileReader();
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
|
||||
input.onchange = () => {
|
||||
if (!input.files!.length) {
|
||||
alert("A file was not selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
reader.readAsText(input.files![0], "utf8");
|
||||
};
|
||||
});
|
||||
|
||||
input.click();
|
||||
|
||||
return new Promise<DataState>(resolve => {
|
||||
reader.onloadend = () => {
|
||||
if (reader.readyState === FileReader.DONE) {
|
||||
const { elements, appState } = updateAppState(
|
||||
reader.result as string
|
||||
);
|
||||
resolve(restore(elements, appState));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getExportCanvasPreview(
|
||||
@ -135,7 +222,7 @@ export function getExportCanvasPreview(
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
export function exportCanvas(
|
||||
export async function exportCanvas(
|
||||
type: ExportType,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
canvas: HTMLCanvasElement,
|
||||
@ -197,7 +284,16 @@ export function exportCanvas(
|
||||
);
|
||||
|
||||
if (type === "png") {
|
||||
saveFile(`${name}.png`, tempCanvas.toDataURL("image/png"));
|
||||
const fileName = `${name}.png`;
|
||||
if ("chooseFileSystemEntries" in window) {
|
||||
tempCanvas.toBlob(async blob => {
|
||||
if (blob) {
|
||||
await saveFileNative(fileName, blob);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
saveFile(fileName, tempCanvas.toDataURL("image/png"));
|
||||
}
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
tempCanvas.toBlob(async function(blob) {
|
||||
|
Loading…
Reference in New Issue
Block a user