snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import type { EDITOR_LS_KEYS } from "@excalidraw/common";
import type { JSONValue } from "../types";
export class EditorLocalStorage {
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
try {
return !!window.localStorage.getItem(key);
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return false;
}
}
static get<T extends JSONValue>(
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) {
try {
const value = window.localStorage.getItem(key);
if (value) {
return JSON.parse(value) as T;
}
return null;
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return null;
}
}
static set = (
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
value: JSONValue,
) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error: any) {
console.warn(`localStorage.setItem error: ${error.message}`);
return false;
}
};
static delete = (
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) => {
try {
window.localStorage.removeItem(name);
} catch (error: any) {
console.warn(`localStorage.removeItem error: ${error.message}`);
}
};
}

View File

@@ -0,0 +1,300 @@
export namespace OpenAIInput {
type ChatCompletionContentPart =
| ChatCompletionContentPartText
| ChatCompletionContentPartImage;
interface ChatCompletionContentPartImage {
image_url: ChatCompletionContentPartImage.ImageURL;
/**
* The type of the content part.
*/
type: "image_url";
}
namespace ChatCompletionContentPartImage {
export interface ImageURL {
/**
* Either a URL of the image or the base64 encoded image data.
*/
url: string;
/**
* Specifies the detail level of the image.
*/
detail?: "auto" | "low" | "high";
}
}
interface ChatCompletionContentPartText {
/**
* The text content.
*/
text: string;
/**
* The type of the content part.
*/
type: "text";
}
interface ChatCompletionUserMessageParam {
/**
* The contents of the user message.
*/
content: string | Array<ChatCompletionContentPart> | null;
/**
* The role of the messages author, in this case `user`.
*/
role: "user";
}
interface ChatCompletionSystemMessageParam {
/**
* The contents of the system message.
*/
content: string | null;
/**
* The role of the messages author, in this case `system`.
*/
role: "system";
}
export interface ChatCompletionCreateParamsBase {
/**
* A list of messages comprising the conversation so far.
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
*/
messages: Array<
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
>;
/**
* ID of the model to use. See the
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
* table for details on which models work with the Chat API.
*/
model:
| (string & {})
| "gpt-4-1106-preview"
| "gpt-4-vision-preview"
| "gpt-4"
| "gpt-4-0314"
| "gpt-4-0613"
| "gpt-4-32k"
| "gpt-4-32k-0314"
| "gpt-4-32k-0613"
| "gpt-3.5-turbo"
| "gpt-3.5-turbo-16k"
| "gpt-3.5-turbo-0301"
| "gpt-3.5-turbo-0613"
| "gpt-3.5-turbo-16k-0613";
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
* existing frequency in the text so far, decreasing the model's likelihood to
* repeat the same line verbatim.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
frequency_penalty?: number | null;
/**
* Modify the likelihood of specified tokens appearing in the completion.
*
* Accepts a JSON object that maps tokens (specified by their token ID in the
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
* bias is added to the logits generated by the model prior to sampling. The exact
* effect will vary per model, but values between -1 and 1 should decrease or
* increase likelihood of selection; values like -100 or 100 should result in a ban
* or exclusive selection of the relevant token.
*/
logit_bias?: Record<string, number> | null;
/**
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
*
* The total length of input tokens and generated tokens is limited by the model's
* context length.
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
* for counting tokens.
*/
max_tokens?: number | null;
/**
* How many chat completion choices to generate for each input message.
*/
n?: number | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
* whether they appear in the text so far, increasing the model's likelihood to
* talk about new topics.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
presence_penalty?: number | null;
/**
* This feature is in Beta. If specified, our system will make a best effort to
* sample deterministically, such that repeated requests with the same `seed` and
* parameters should return the same result. Determinism is not guaranteed, and you
* should refer to the `system_fingerprint` response parameter to monitor changes
* in the backend.
*/
seed?: number | null;
/**
* Up to 4 sequences where the API will stop generating further tokens.
*/
stop?: string | null | Array<string>;
/**
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
* sent as data-only
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
* as they become available, with the stream terminated by a `data: [DONE]`
* message.
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
*/
stream?: boolean | null;
/**
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
* make the output more random, while lower values like 0.2 will make it more
* focused and deterministic.
*
* We generally recommend altering this or `top_p` but not both.
*/
temperature?: number | null;
/**
* An alternative to sampling with temperature, called nucleus sampling, where the
* model considers the results of the tokens with top_p probability mass. So 0.1
* means only the tokens comprising the top 10% probability mass are considered.
*
* We generally recommend altering this or `temperature` but not both.
*/
top_p?: number | null;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
*/
user?: string;
}
}
export namespace OpenAIOutput {
export interface ChatCompletion {
/**
* A unique identifier for the chat completion.
*/
id: string;
/**
* A list of chat completion choices. Can be more than one if `n` is greater
* than 1.
*/
choices: Array<Choice>;
/**
* The Unix timestamp (in seconds) of when the chat completion was created.
*/
created: number;
/**
* The model used for the chat completion.
*/
model: string;
/**
* The object type, which is always `chat.completion`.
*/
object: "chat.completion";
/**
* This fingerprint represents the backend configuration that the model runs with.
*
* Can be used in conjunction with the `seed` request parameter to understand when
* backend changes have been made that might impact determinism.
*/
system_fingerprint?: string;
/**
* Usage statistics for the completion request.
*/
usage?: CompletionUsage;
}
export interface Choice {
/**
* The reason the model stopped generating tokens. This will be `stop` if the model
* hit a natural stop point or a provided stop sequence, `length` if the maximum
* number of tokens specified in the request was reached, `content_filter` if
* content was omitted due to a flag from our content filters, `tool_calls` if the
* model called a tool, or `function_call` (deprecated) if the model called a
* function.
*/
finish_reason:
| "stop"
| "length"
| "tool_calls"
| "content_filter"
| "function_call";
/**
* The index of the choice in the list of choices.
*/
index: number;
/**
* A chat completion message generated by the model.
*/
message: ChatCompletionMessage;
}
interface ChatCompletionMessage {
/**
* The contents of the message.
*/
content: string | null;
/**
* The role of the author of this message.
*/
role: "assistant";
}
/**
* Usage statistics for the completion request.
*/
interface CompletionUsage {
/**
* Number of tokens in the generated completion.
*/
completion_tokens: number;
/**
* Number of tokens in the prompt.
*/
prompt_tokens: number;
/**
* Total number of tokens used in the request (prompt + completion).
*/
total_tokens: number;
}
export interface APIError {
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
readonly headers: Headers | undefined;
readonly error: { message: string } | undefined;
readonly code: string | null | undefined;
readonly param: string | null | undefined;
readonly type: string | undefined;
}
}

View File

@@ -0,0 +1,560 @@
import { nanoid } from "nanoid";
import {
IMAGE_MIME_TYPES,
MIME_TYPES,
bytesToHexString,
isPromiseLike,
} from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import { cleanAppStateForExport } from "../appState";
import { CanvasError, ImageSceneDataError } from "../errors";
import { getScrollToContentState } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import {
restoreAppState,
restoreElements,
restoreLibraryItems,
} from "./restore";
import type { AppState, DataURL, LibraryItem } from "../types";
import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => {
let contents: string;
if (blob.type === MIME_TYPES.png) {
try {
return await (await import("./image")).decodePngMetadata(blob);
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError("Error: cannot restore image");
}
}
} else {
if ("text" in Blob) {
contents = await blob.text();
} else {
contents = await new Promise((resolve) => {
const reader = new FileReader();
reader.readAsText(blob, "utf8");
reader.onloadend = () => {
if (reader.readyState === FileReader.DONE) {
resolve(reader.result as string);
}
};
});
}
if (blob.type === MIME_TYPES.svg) {
try {
return decodeSvgBase64Payload({
svg: contents,
});
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError("Error: cannot restore image");
}
}
}
}
return contents;
};
export const getMimeType = (blob: Blob | string): string => {
let name: string;
if (typeof blob === "string") {
name = blob;
} else {
if (blob.type) {
return blob.type;
}
name = blob.name || "";
}
if (/\.(excalidraw|json)$/.test(name)) {
return MIME_TYPES.json;
} else if (/\.png$/.test(name)) {
return MIME_TYPES.png;
} else if (/\.jpe?g$/.test(name)) {
return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg;
} else if (/\.excalidrawlib$/.test(name)) {
return MIME_TYPES.excalidrawlib;
}
return "";
};
export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
if (!handle) {
return null;
}
return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
};
export const isImageFileHandleType = (
type: string | null,
): type is "png" | "svg" => {
return type === "png" || type === "svg";
};
export const isImageFileHandle = (
handle: FileSystemFileHandle | null,
): handle is FileSystemFileHandle => {
const type = getFileHandleType(handle);
return type === "png" || type === "svg";
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return isSupportedImageFileType(type);
};
export const loadSceneOrLibraryFromBlob = async (
blob: Blob | File,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemFileHandle | null,
) => {
const contents = await parseFileContents(blob);
let data;
try {
try {
data = JSON.parse(contents);
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
}
throw error;
}
if (isValidExcalidrawData(data)) {
const elements = restoreElements(data.elements, localElements, {
repairBindings: true,
deleteInvisibleElements: true,
});
return {
type: MIME_TYPES.excalidraw,
data: {
elements,
appState: restoreAppState(
{
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? getScrollToContentState(elements, localAppState)
: {}),
},
localAppState,
),
files: data.files || {},
},
};
} else if (isValidLibrary(data)) {
return {
type: MIME_TYPES.excalidrawlib,
data,
};
}
throw new Error("Error: invalid file");
} catch (error: any) {
if (error instanceof ImageSceneDataError) {
throw error;
}
throw new Error("Error: invalid file");
}
};
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemFileHandle | null,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
localAppState,
localElements,
fileHandle,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error("Error: invalid file");
}
return ret.data;
};
export const parseLibraryJSON = (
json: string,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const data: ImportedLibraryData | undefined = JSON.parse(json);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
}
const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
};
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
};
export const canvasToBlob = async (
canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>,
): Promise<Blob> => {
return new Promise(async (resolve, reject) => {
try {
if (isPromiseLike(canvas)) {
canvas = await canvas;
}
canvas.toBlob((blob) => {
if (!blob) {
return reject(
new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"),
);
}
resolve(blob);
});
} catch (error: any) {
reject(error);
}
});
};
/** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File): Promise<FileId> => {
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await blobToArrayBuffer(file),
);
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
} catch (error: any) {
console.error(error);
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
return nanoid(40) as FileId;
}
};
/** async. For sync variant, use getDataURL_sync */
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataURL = reader.result as DataURL;
resolve(dataURL);
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
export const getDataURL_sync = (
data: string | Uint8Array | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
): DataURL => {
return `data:${mimeType};base64,${stringToBase64(
toByteString(data),
true,
)}` as DataURL;
};
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1));
const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new File([ab], filename, { type: mimeType });
};
export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
const getImageFileDimensions = async (file: File) => {
const browserURL = typeof window !== "undefined" ? window.URL : undefined;
let objectURL: string | null = null;
let imageSource: string;
try {
imageSource = browserURL?.createObjectURL
? (objectURL = browserURL.createObjectURL(file))
: await getDataURL(file);
} catch {
objectURL = null;
imageSource = await getDataURL(file);
}
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
const cleanup = () => {
image.onload = null;
image.onerror = null;
if (objectURL && browserURL?.revokeObjectURL) {
browserURL.revokeObjectURL(objectURL);
}
};
image.onload = () => {
cleanup();
resolve({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
});
};
image.onerror = (error) => {
cleanup();
reject(error);
};
image.src = imageSource;
});
};
export const resizeImageFile = async (
file: File,
opts: {
/** undefined indicates auto */
outputType?: typeof MIME_TYPES["jpg"];
maxWidthOrHeight: number;
},
): Promise<File> => {
// SVG files shouldn't a can't be resized
if (file.type === MIME_TYPES.svg) {
return file;
}
if (!isSupportedImageFile(file)) {
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
if (!opts.outputType || opts.outputType === file.type) {
const dimensions = await getImageFileDimensions(file);
if (
Math.max(dimensions.width, dimensions.height) <= opts.maxWidthOrHeight
) {
return file;
}
}
const [pica, imageBlobReduce] = await Promise.all([
import("pica").then((res) => res.default),
// a wrapper for pica for better API
import("image-blob-reduce").then((res) => res.default),
]);
// CRA's minification settings break pica in WebWorkers, so let's disable
// them for now
// https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
const reduce = imageBlobReduce({
pica: pica({ features: ["js", "wasm"] }),
});
if (opts.outputType) {
const { outputType } = opts;
reduce._create_blob = function (env) {
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
env.out_blob = blob;
return env;
});
};
}
return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name,
{
type: opts.outputType || file.type,
},
);
};
export const SVGStringToFile = (SVGString: string, filename: string = "") => {
return new File([new TextEncoder().encode(SVGString)], filename, {
type: MIME_TYPES.svg,
}) as File & { type: typeof MIME_TYPES.svg };
};
export const ImageURLToFile = async (
imageUrl: string,
filename: string = "",
): Promise<File | undefined> => {
let response;
try {
response = await fetch(imageUrl);
} catch (error: any) {
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
}
if (!response.ok) {
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
}
const blob = await response.blob();
if (blob.type && isSupportedImageFile(blob)) {
const name = filename || blob.name || "";
return new File([blob], name, { type: blob.type });
}
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
};
export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemFileHandle | null> => {
if (nativeFileSystemSupported) {
try {
const dataTransferItem =
event instanceof DataTransferItem
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemFileHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
return handle;
} catch (error: any) {
console.warn(error.name, error.message);
return null;
}
}
return null;
};
/**
* attempts to detect if a buffer is a valid image by checking its leading bytes
*/
const getActualMimeTypeFromImage = async (file: Blob | File) => {
let mimeType: ValueOf<
Pick<typeof MIME_TYPES, "png" | "jpg" | "gif" | "webp">
> | null = null;
const leadingBytes = [
...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))),
].join(" ");
// uint8 leading bytes
const bytes = {
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
png: /^137 80 78 71 13 10 26 10\b/,
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
// jpg is a bit wonky. Checking the first three bytes should be enough,
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
jpg: /^255 216 255\b/,
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
gif: /^71 73 70 56 57 97\b/,
// 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier
webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/,
};
for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) {
if (leadingBytes.match(bytes[type])) {
mimeType = MIME_TYPES[type];
break;
}
}
return mimeType || file.type || null;
};
export const createFile = (
blob: File | Blob | ArrayBuffer,
mimeType: string,
name: string | undefined,
) => {
return new File([blob], name || "", {
type: mimeType,
});
};
const normalizedFileSymbol = Symbol("fileNormalized");
/** attempts to detect correct mimeType if none is set, or if an image
* has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => {
// to prevent double normalization (perf optim)
if ((file as any)[normalizedFileSymbol]) {
return file;
}
if (file?.name?.endsWith(".excalidrawlib")) {
file = createFile(file, MIME_TYPES.excalidrawlib, file.name);
} else if (file?.name?.endsWith(".excalidraw")) {
file = createFile(file, MIME_TYPES.excalidraw, file.name);
} else if (!file.type || file.type?.startsWith("image/")) {
// when the file is an image, make sure the extension corresponds to the
// actual mimeType (this is an edge case, but happens - especially
// with AI generated images)
const mimeType = await getActualMimeTypeFromImage(file);
if (mimeType && mimeType !== file.type) {
file = createFile(file, mimeType, file.name);
}
}
(file as any)[normalizedFileSymbol] = true;
return file;
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};

View File

@@ -0,0 +1,414 @@
import { deflate, inflate } from "pako";
import { encryptData, decryptData } from "./encryption";
// -----------------------------------------------------------------------------
// byte (binary) strings
// -----------------------------------------------------------------------------
// Buffer-compatible implem.
//
// Note that in V8, spreading the uint8array (by chunks) into
// `String.fromCharCode(...uint8array)` tends to be faster for large
// strings/buffers, in case perf is needed in the future.
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
const bytes =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: new Uint8Array(data);
let bstring = "";
for (const byte of bytes) {
bstring += String.fromCharCode(byte);
}
return bstring;
};
const byteStringToArrayBuffer = (byteString: string) => {
const buffer = new ArrayBuffer(byteString.length);
const bufferView = new Uint8Array(buffer);
for (let i = 0, len = byteString.length; i < len; i++) {
bufferView[i] = byteString.charCodeAt(i);
}
return buffer;
};
const byteStringToString = (byteString: string) => {
return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString));
};
// -----------------------------------------------------------------------------
// base64
// -----------------------------------------------------------------------------
/**
* @param isByteString set to true if already byte string to prevent bloat
* due to reencoding
*/
export const stringToBase64 = (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
};
// async to align with stringToBase64
export const base64ToString = (base64: string, isByteString = false) => {
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
};
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(base64, "base64").buffer;
}
// Browser environment
return byteStringToArrayBuffer(atob(base64));
};
// -----------------------------------------------------------------------------
// base64url
// -----------------------------------------------------------------------------
export const base64urlToString = (str: string) => {
return window.atob(
// normalize base64URL to base64
str
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
);
};
// -----------------------------------------------------------------------------
// text encoding
// -----------------------------------------------------------------------------
type EncodedData = {
encoded: string;
encoding: "bstring";
/** whether text is compressed (zlib) */
compressed: boolean;
/** version for potential migration purposes */
version?: string;
};
/**
* Encodes (and potentially compresses via zlib) text to byte string
*/
export const encode = ({
text,
compress,
}: {
text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean;
}): EncodedData => {
let deflated!: string;
if (compress !== false) {
try {
deflated = toByteString(deflate(text));
} catch (error: any) {
console.error("encode: cannot deflate", error);
}
}
return {
version: "1",
encoding: "bstring",
compressed: !!deflated,
encoded: deflated || toByteString(text),
};
};
export const decode = (data: EncodedData): string => {
let decoded: string;
switch (data.encoding) {
case "bstring":
// if compressed, do not double decode the bstring
decoded = data.compressed
? data.encoded
: byteStringToString(data.encoded);
break;
default:
throw new Error(`decode: unknown encoding "${data.encoding}"`);
}
if (data.compressed) {
return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), {
to: "string",
});
}
return decoded;
};
// -----------------------------------------------------------------------------
// binary encoding
// -----------------------------------------------------------------------------
type FileEncodingInfo = {
/* version 2 is the version we're shipping the initial image support with.
version 1 was a PR version that a lot of people were using anyway.
Thus, if there are issues we can check whether they're not using the
unoffic version */
version: 1 | 2;
compression: "pako@1" | null;
encryption: "AES-GCM" | null;
};
// -----------------------------------------------------------------------------
const CONCAT_BUFFERS_VERSION = 1;
/** how many bytes we use to encode how many bytes the next chunk has.
* Corresponds to DataView setter methods (setUint32, setUint16, etc).
*
* NOTE ! values must not be changed, which would be backwards incompatible !
*/
const VERSION_DATAVIEW_BYTES = 4;
const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
// -----------------------------------------------------------------------------
const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
// getter
function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
// setter
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value: number,
): Uint8Array;
/**
* abstraction over DataView that serves as a typed getter/setter in case
* you're using constants for the byte size and want to ensure there's no
* discrepenancy in the encoding across refactors.
*
* DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
*/
function dataView(
buffer: Uint8Array,
bytes: 1 | 2 | 4,
offset: number,
value?: number,
): Uint8Array | number {
if (value != null) {
if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
throw new Error(
`attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
);
}
const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
new DataView(buffer.buffer)[method](offset, value);
return buffer;
}
const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
return new DataView(buffer.buffer)[method](offset);
}
// -----------------------------------------------------------------------------
/**
* Resulting concatenated buffer has this format:
*
* [
* VERSION chunk (4 bytes)
* LENGTH chunk 1 (4 bytes)
* DATA chunk 1 (up to 2^32 bits)
* LENGTH chunk 2 (4 bytes)
* DATA chunk 2 (up to 2^32 bits)
* ...
* ]
*
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/
const concatBuffers = (...buffers: Uint8Array[]): Uint8Array<ArrayBuffer> => {
const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
);
let cursor = 0;
// as the first chunk we'll encode the version for backwards compatibility
dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
cursor += VERSION_DATAVIEW_BYTES;
for (const buffer of buffers) {
dataView(
bufferView,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
buffer.byteLength,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
bufferView.set(buffer, cursor);
cursor += buffer.byteLength;
}
return bufferView;
};
/** can only be used on buffers created via `concatBuffers()` */
const splitBuffers = (concatenatedBuffer: Uint8Array) => {
const buffers = [];
let cursor = 0;
// first chunk is the version
const version = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
// If version is outside of the supported versions, throw an error.
// This usually means the buffer wasn't encoded using this API, so we'd only
// waste compute.
if (version > CONCAT_BUFFERS_VERSION) {
throw new Error(`invalid version ${version}`);
}
cursor += VERSION_DATAVIEW_BYTES;
while (true) {
const chunkSize = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
cursor += chunkSize;
if (cursor >= concatenatedBuffer.byteLength) {
break;
}
}
return buffers;
};
// helpers for (de)compressing data with JSON metadata including encryption
// -----------------------------------------------------------------------------
/** @private */
const _encryptAndCompress = async (
data: Uint8Array<ArrayBuffer> | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data) as Uint8Array<ArrayBuffer>,
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
};
/**
* The returned buffer has following format:
* `[]` refers to a buffers wrapper (see `concatBuffers`)
*
* [
* encodingMetadataBuffer,
* iv,
* [
* contentsMetadataBuffer
* contentsBuffer
* ]
* ]
*/
export const compressData = async <T extends Record<string, any> = never>(
dataBuffer: Uint8Array,
options: {
encryptionKey: string;
} & ([T] extends [never]
? {
metadata?: T;
}
: {
metadata: T;
}),
): Promise<Uint8Array<ArrayBuffer>> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
encryption: "AES-GCM",
};
const encodingMetadataBuffer = new TextEncoder().encode(
JSON.stringify(fileInfo),
);
const contentsMetadataBuffer = new TextEncoder().encode(
JSON.stringify(options.metadata || null),
);
const { iv, buffer } = await _encryptAndCompress(
concatBuffers(contentsMetadataBuffer, dataBuffer),
options.encryptionKey,
);
return concatBuffers(encodingMetadataBuffer, iv, buffer);
};
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array<ArrayBuffer>,
decryptedBuffer: Uint8Array<ArrayBuffer>,
decryptionKey: string,
isCompressed: boolean,
) => {
decryptedBuffer = new Uint8Array(
await decryptData(iv, decryptedBuffer, decryptionKey),
);
if (isCompressed) {
return inflate(decryptedBuffer);
}
return decryptedBuffer;
};
export const decompressData = async <T extends Record<string, any>>(
bufferView: Uint8Array,
options: { decryptionKey: string },
) => {
// first chunk is encoding metadata (ignored for now)
const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
const encodingMetadata: FileEncodingInfo = JSON.parse(
new TextDecoder().decode(encodingMetadataBuffer),
);
try {
const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
await _decryptAndDecompress(
iv,
buffer,
options.decryptionKey,
!!encodingMetadata.compression,
),
);
const metadata = JSON.parse(
new TextDecoder().decode(contentsMetadataBuffer),
) as T;
return {
/** metadata source is always JSON so we can decode it here */
metadata,
/** data can be anything so the caller must decode it */
data: contentsBuffer,
};
} catch (error: any) {
console.error(
`Error during decompressing and decrypting the file.`,
encodingMetadata,
);
throw error;
}
};
// -----------------------------------------------------------------------------

View File

@@ -0,0 +1,94 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
true, // extractable
["encrypt", "decrypt"],
);
return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
};
export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string | CryptoKey,
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array<ArrayBuffer>,
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};

View File

@@ -0,0 +1,75 @@
import {
fileOpen as _fileOpen,
fileSave as _fileSave,
supported as nativeFileSystemSupported,
} from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/common";
import { normalizeFile } from "./blob";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
description: string;
multiple?: M;
}): Promise<M extends false | undefined ? File : File[]> => {
// an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined ? File : File[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
const extensions = opts.extensions?.reduce((acc, ext) => {
if (ext === "jpg") {
return acc.concat(".jpg", ".jpeg");
}
return acc.concat(`.${ext}`);
}, [] as string[]);
const files = await _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
});
if (Array.isArray(files)) {
return (await Promise.all(
files.map((file) => normalizeFile(file)),
)) as RetType;
}
return (await normalizeFile(files)) as RetType;
};
export const fileSave = (
blob: Blob | Promise<Blob>,
opts: {
/** supply without the extension */
name: string;
/** file extension */
extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string;
/** existing FileSystemFileHandle */
fileHandle?: FileSystemFileHandle | null;
},
) => {
return _fileSave(
blob,
{
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
},
opts.fileHandle,
false,
);
};
export { nativeFileSystemSupported };

View File

@@ -0,0 +1,71 @@
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import decodePng from "png-chunks-extract";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "@excalidraw/common";
import { blobToArrayBuffer } from "./blob";
import { encode, decode } from "./encode";
// -----------------------------------------------------------------------------
// PNG
// -----------------------------------------------------------------------------
export const getTEXtChunk = async (
blob: Blob,
): Promise<{ keyword: string; text: string } | null> => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
if (metadataChunk) {
return tEXt.decode(metadataChunk.data);
}
return null;
};
export const encodePngMetadata = async ({
blob,
metadata,
}: {
blob: Blob;
metadata: string;
}) => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw,
JSON.stringify(
encode({
text: metadata,
compress: true,
}),
),
);
// insert metadata before last chunk (iEND)
chunks.splice(-1, 0, metadataChunk);
return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
};
export const decodePngMetadata = async (blob: Blob) => {
const metadata = await getTEXtChunk(blob);
if (metadata?.keyword === MIME_TYPES.excalidraw) {
try {
const encodedData = JSON.parse(metadata.text);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return metadata.text;
}
throw new Error("FAILED");
}
return decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};

View File

@@ -0,0 +1,219 @@
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox,
MIME_TYPES,
cloneJSON,
SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsOverlappingFrame } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { canvasToBlob } from "./blob";
import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json";
import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
_brand: "exportedElements";
};
export const prepareElementsForExport = (
allElements: readonly ExcalidrawElement[],
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
exportSelectionOnly: boolean,
) => {
const elements = getNonDeletedElements(allElements);
const elementsMap = arrayToMap(elements);
const isExportingSelection =
exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: NonDeleted<ExcalidrawFrameLikeElement> | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
},
)
: elements;
if (isExportingSelection) {
const firstElement = exportedElements[0];
if (exportedElements.length === 1 && isFrameLikeElement(firstElement)) {
exportingFrame = firstElement;
exportedElements = getElementsOverlappingFrame(
elements,
firstElement,
elementsMap,
);
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
}
}
return {
exportingFrame,
exportedElements: cloneJSON(exportedElements) as ExportedElements,
};
};
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
elements: ExportedElements,
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
name = appState.name || DEFAULT_FILENAME,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
/** filename, if applicable */
name?: string;
fileHandle?: FileSystemFileHandle | null;
exportingFrame: NonDeleted<ExcalidrawFrameLikeElement> | null;
},
) => {
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return fileSave(
svgPromise.then((svg) => {
// adding SVG preamble so that older software parse the SVG file
// properly
return new Blob([SVG_DOCUMENT_PREAMBLE + svg.outerHTML], {
type: MIME_TYPES.svg,
});
}),
{
description: "Export to SVG",
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle,
},
);
} else if (type === "clipboard-svg") {
const svg = await svgPromise.then((svg) => svg.outerHTML);
try {
await copyTextToSystemClipboard(svg);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return;
}
}
const tempCanvas = exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {
let blob = canvasToBlob(tempCanvas);
if (appState.exportEmbedScene) {
blob = blob.then((blob) =>
import("./image").then(({ encodePngMetadata }) =>
encodePngMetadata({
blob,
metadata: serializeAsJSON(elements, appState, files, "local"),
}),
),
);
}
return fileSave(blob, {
description: "Export to PNG",
name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
mimeTypes: [IMAGE_MIME_TYPES.png],
fileHandle,
});
} else if (type === "clipboard") {
try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob);
} catch (error: any) {
console.warn(error);
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
// TypeError *probably* suggests ClipboardItem not defined, which
// people on Firefox can enable through a flag, so let's tell them.
if (isFirefox && error.name === "TypeError") {
throw new Error(
`${t("alerts.couldNotCopyToClipboard")}\n\n${t(
"hints.firefox_clipboard_write",
)}`,
);
} else {
throw new Error(t("alerts.couldNotCopyToClipboard"));
}
}
} else {
// shouldn't happen
throw new Error("Unsupported export type");
}
};

View File

@@ -0,0 +1,159 @@
import {
EXPORT_DATA_TYPES,
getExportSource,
MIME_TYPES,
VERSIONS,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import { isImageFileHandle, loadFromBlob } from "./blob";
import { fileOpen, fileSave } from "./filesystem";
import type { AppState, BinaryFiles, LibraryItems } from "../types";
import type {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
export type JSONExportData = {
elements: readonly ExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
};
/**
* Strips out files which are only referenced by deleted elements
*/
const filterOutDeletedFiles = (
elements: readonly ExcalidrawElement[],
files: BinaryFiles,
) => {
const nextFiles: BinaryFiles = {};
for (const element of elements) {
if (
!element.isDeleted &&
"fileId" in element &&
element.fileId &&
files[element.fileId]
) {
nextFiles[element.fileId] = files[element.fileId];
}
}
return nextFiles;
};
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
type: "local" | "database",
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw,
source: getExportSource(),
elements,
appState:
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
files:
type === "local"
? filterOutDeletedFiles(elements, files)
: // will be stripped from JSON
undefined,
};
return JSON.stringify(data, null, 2);
};
export const saveAsJSON = async ({
data,
filename,
fileHandle,
}: {
data: MaybePromise<JSONExportData>;
filename: string;
fileHandle: AppState["fileHandle"];
}) => {
const blob = Promise.resolve(data).then(({ elements, appState, files }) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
return new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
});
const savedFileHandle = await fileSave(blob, {
name: filename,
extension: "excalidraw",
description: "Excalidraw file",
fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
});
return { fileHandle: savedFileHandle };
};
export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
const file = await fileOpen({
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"],
});
return loadFromBlob(file, localAppState, localElements, file.handle);
};
export const isValidExcalidrawData = (data?: {
type?: any;
elements?: any;
appState?: any;
}): data is ImportedDataState => {
return (
data?.type === EXPORT_DATA_TYPES.excalidraw &&
(!data.elements ||
(Array.isArray(data.elements) &&
(!data.appState || typeof data.appState === "object")))
);
};
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
return (
typeof json === "object" &&
json &&
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
(json.version === 1 || json.version === 2)
);
};
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
source: getExportSource(),
libraryItems,
};
return JSON.stringify(data, null, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const serialized = serializeLibraryAsJSON(libraryItems);
await fileSave(
new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
}),
{
name: "library",
extension: "excalidrawlib",
description: "Excalidraw library file",
},
);
};

View File

@@ -0,0 +1,105 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import throttle from "lodash.throttle";
import { arrayToMap, isDevEnv, isTestEnv } from "@excalidraw/common";
import {
orderByFractionalIndex,
syncInvalidIndices,
validateFractionalIndices,
} from "@excalidraw/element";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type { MakeBrand } from "@excalidraw/common/utility-types";
import type { AppState } from "../types";
export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"ReconciledElement">;
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"RemoteExcalidrawElement">;
export const shouldDiscardRemoteElement = (
localAppState: AppState,
local: OrderedExcalidrawElement | undefined,
remote: RemoteExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingTextElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.newElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce <= remote.versionNonce))
) {
return true;
}
return false;
};
const validateIndicesThrottled = throttle(
(
orderedElements: readonly OrderedExcalidrawElement[],
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
) => {
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
// create new instances due to the mutation
const elements = syncInvalidIndices(
orderedElements.map((x) => ({ ...x })),
);
validateFractionalIndices(elements, {
// throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: isTestEnv() || isDevEnv(),
includeBoundTextValidation: true,
reconciliationContext: {
localElements,
remoteElements,
},
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
localAppState: AppState,
): ReconciledExcalidrawElement[] => {
const localElementsMap = arrayToMap(localElements);
const reconciledElements: OrderedExcalidrawElement[] = [];
const added = new Set<string>();
// process remote elements
for (const remoteElement of remoteElements) {
if (!added.has(remoteElement.id)) {
const localElement = localElementsMap.get(remoteElement.id);
const discardRemoteElement = shouldDiscardRemoteElement(
localAppState,
localElement,
remoteElement,
);
if (localElement && discardRemoteElement) {
reconciledElements.push(localElement);
added.add(localElement.id);
} else {
reconciledElements.push(remoteElement);
added.add(remoteElement.id);
}
}
}
// process remaining local elements
for (const localElement of localElements) {
if (!added.has(localElement.id)) {
reconciledElements.push(localElement);
added.add(localElement.id);
}
}
const orderedElements = orderByFractionalIndex(reconciledElements);
validateIndicesThrottled(orderedElements, localElements, remoteElements);
// de-duplicate indices
syncInvalidIndices(orderedElements);
return orderedElements as ReconciledExcalidrawElement[];
};

View File

@@ -0,0 +1,53 @@
import type { MaybePromise } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getFileHandleType, isImageFileHandleType } from "./blob";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { exportCanvas, prepareElementsForExport } from ".";
import type { AppState, BinaryFiles } from "../types";
export const resaveAsImageWithScene = async (
data: MaybePromise<{
elements: readonly ExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
}>,
fileHandle: FileSystemFileHandle,
filename: string,
) => {
const fileHandleType = getFileHandleType(fileHandle);
if (!isImageFileHandleType(fileHandleType)) {
throw new Error(
"fileHandle should exist and should be of type svg or png when resaving",
);
}
let { elements, appState, files } = await data;
const { exportBackground, viewBackgroundColor } = appState;
appState = {
...appState,
exportEmbedScene: true,
};
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
false,
);
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name: filename,
fileHandle,
exportingFrame,
});
return { fileHandle };
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import type { VERSIONS } from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { cleanAppStateForExport } from "../appState";
import type {
AppState,
BinaryFiles,
LibraryItem,
LibraryItems,
LibraryItems_anyVersion,
} from "../types";
export interface ExportedDataState {
type: string;
version: number;
source: string;
elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>;
files: BinaryFiles | undefined;
}
/**
* Map of legacy AppState keys, with values of:
* [<legacy type>, <new AppState proeprty>]
*
* This is a helper type used in downstream abstractions.
* Don't consume on its own.
*/
export type LegacyAppState = {
/** @deprecated #6213 TODO remove 23-06-01 */
isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
};
export interface ImportedDataState {
type?: string;
version?: number;
source?: string;
elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<
Partial<
AppState & {
[T in keyof LegacyAppState]: LegacyAppState[T][0];
}
>
> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems_anyVersion;
files?: BinaryFiles;
}
export interface ExportedLibraryData {
type: string;
version: typeof VERSIONS.excalidrawLibrary;
source: string;
libraryItems: LibraryItems;
}
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
/** @deprecated v1 */
library?: LibraryItems;
}
export type ExcalidrawLibraryIds = {
itemIds: LibraryItem["id"][];
};