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"
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta http-equiv="origin-trial" content="AvB+3K1LqGdVR+XcHhjpdM0yl5/RtR/v7MIO/nbgNnLZHJ5yMYos9kZAQiuc0EEZne4d9CzHhF2sk2fUPOylcgUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4Mjg3ODU4NH0=">
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
@ -39,6 +39,10 @@ export const actionClearCanvas: Action = {
|
|||||||
aria-label="Clear the canvas & reset background color"
|
aria-label="Clear the canvas & reset background color"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm("This will clear the whole canvas. Are you sure?")) {
|
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);
|
updateData(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -40,7 +40,7 @@ export const actionChangeExportBackground: Action = {
|
|||||||
export const actionSaveScene: Action = {
|
export const actionSaveScene: Action = {
|
||||||
name: "saveScene",
|
name: "saveScene",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
saveAsJSON(elements, appState);
|
saveAsJSON(elements, appState).catch(err => console.error(err));
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
@ -70,9 +70,11 @@ export const actionLoadScene: Action = {
|
|||||||
title="Load"
|
title="Load"
|
||||||
aria-label="Load"
|
aria-label="Load"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loadFromJSON().then(({ elements, appState }) => {
|
loadFromJSON()
|
||||||
updateData({ elements: elements, appState: appState });
|
.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 = "excalidraw";
|
||||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
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) {
|
function saveFile(name: string, data: string) {
|
||||||
// create a temporary <a> elem which we'll use to download the image
|
// create a temporary <a> elem which we'll use to download the image
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@ -24,12 +29,54 @@ function saveFile(name: string, data: string) {
|
|||||||
link.remove();
|
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 {
|
interface DataState {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAsJSON(
|
export async function saveAsJSON(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState
|
appState: AppState
|
||||||
) {
|
) {
|
||||||
@ -40,46 +87,86 @@ export function saveAsJSON(
|
|||||||
appState: appState
|
appState: appState
|
||||||
});
|
});
|
||||||
|
|
||||||
saveFile(
|
const name = `${appState.name}.json`;
|
||||||
`${appState.name}.json`,
|
if ("chooseFileSystemEntries" in window) {
|
||||||
"data:text/plain;charset=utf-8," + encodeURIComponent(serialized)
|
await saveFileNative(
|
||||||
);
|
name,
|
||||||
|
new Blob([serialized], { type: "application/json" })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
saveFile(
|
||||||
|
name,
|
||||||
|
"data:text/plain;charset=utf-8," + encodeURIComponent(serialized)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadFromJSON() {
|
export async function loadFromJSON() {
|
||||||
const input = document.createElement("input");
|
const updateAppState = (contents: string) => {
|
||||||
const reader = new FileReader();
|
const defaultAppState = getDefaultAppState();
|
||||||
input.type = "file";
|
let elements = [];
|
||||||
input.accept = ".json";
|
let appState = defaultAppState;
|
||||||
|
try {
|
||||||
input.onchange = () => {
|
const data = JSON.parse(contents);
|
||||||
if (!input.files!.length) {
|
elements = data.elements || [];
|
||||||
alert("A file was not selected.");
|
appState = { ...defaultAppState, ...data.appState };
|
||||||
return;
|
} catch (e) {
|
||||||
|
// Do nothing because elements array is already empty
|
||||||
}
|
}
|
||||||
|
return { elements, appState };
|
||||||
reader.readAsText(input.files![0], "utf8");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
input.click();
|
if ("chooseFileSystemEntries" in window) {
|
||||||
|
try {
|
||||||
return new Promise<DataState>(resolve => {
|
(window as any).handle = await (window as any).chooseFileSystemEntries({
|
||||||
reader.onloadend = () => {
|
accepts: [
|
||||||
if (reader.readyState === FileReader.DONE) {
|
{
|
||||||
const defaultAppState = getDefaultAppState();
|
description: "Excalidraw files",
|
||||||
let elements = [];
|
extensions: ["json"],
|
||||||
let appState = defaultAppState;
|
mimeTypes: ["application/json"]
|
||||||
try {
|
}
|
||||||
const data = JSON.parse(reader.result as string);
|
]
|
||||||
elements = data.elements || [];
|
});
|
||||||
appState = { ...defaultAppState, ...data.appState };
|
const file = await (window as any).handle.getFile();
|
||||||
} catch (e) {
|
const contents = await file.text();
|
||||||
// Do nothing because elements array is already empty
|
const { elements, appState } = updateAppState(contents);
|
||||||
}
|
return new Promise<DataState>(resolve => {
|
||||||
resolve(restore(elements, appState));
|
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(
|
export function getExportCanvasPreview(
|
||||||
@ -135,7 +222,7 @@ export function getExportCanvasPreview(
|
|||||||
return tempCanvas;
|
return tempCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportCanvas(
|
export async function exportCanvas(
|
||||||
type: ExportType,
|
type: ExportType,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@ -197,7 +284,16 @@ export function exportCanvas(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (type === "png") {
|
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") {
|
} else if (type === "clipboard") {
|
||||||
try {
|
try {
|
||||||
tempCanvas.toBlob(async function(blob) {
|
tempCanvas.toBlob(async function(blob) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user