build: decouple package deps and introduce yarn workspaces (#7415)

* feat: decouple package deps and introduce yarn workspaces

* update root directory

* fix

* fix scripts

* fix lint

* update path in scripts

* remove yarn.lock files from packages

* ignore workspace

* dummy

* dummy

* remove comment check

* revert workflow changes

* ignore ws when installing gh actions

* remove log

* update path

* fix

* fix typo
This commit is contained in:
Aakansha Doshi 2023-12-12 11:32:51 +05:30 committed by GitHub
parent b7d7ccc929
commit d6cd8b78f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
567 changed files with 5066 additions and 8648 deletions

View file

@ -0,0 +1,51 @@
import { EDITOR_LS_KEYS } from "../constants";
import { 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}`);
}
};
}

File diff suppressed because it is too large Load diff

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,482 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if (blob.type === MIME_TYPES.png) {
try {
return await (
await import(/* webpackChunkName: "image" */ "./image")
).decodePngMetadata(blob);
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
}
}
} 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 await (
await import(/* webpackChunkName: "image" */ "./image")
).decodeSvgMetadata({
svg: contents,
});
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
}
}
}
}
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;
}
return "";
};
export const getFileHandleType = (handle: FileSystemHandle | 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: FileSystemHandle | null) => {
const type = getFileHandleType(handle);
return type === "png" || type === "svg";
};
export const isSupportedImageFile = (
blob: Blob | null | undefined,
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {};
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const loadSceneOrLibraryFromBlob = async (
blob: Blob | File,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const contents = await parseFileContents(blob);
let data;
try {
try {
data = JSON.parse(contents);
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
}
throw error;
}
if (isValidExcalidrawData(data)) {
return {
type: MIME_TYPES.excalidraw,
data: restore(
{
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState)
: {}),
},
files: data.files,
},
localAppState,
localElements,
{ repairBindings: true, refreshDimensions: false },
),
};
} else if (isValidLibrary(data)) {
return {
type: MIME_TYPES.excalidrawlib,
data,
};
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
} catch (error: any) {
if (error instanceof ImageSceneDataError) {
throw error;
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
};
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
localAppState,
localElements,
fileHandle,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
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(
t("canvasError.canvasTooBig"),
"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;
}
};
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 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 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;
}
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;
});
};
}
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
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(t("errors.failedToFetchImage"));
}
if (!response.ok) {
throw new Error(t("errors.failedToFetchImage"));
}
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(t("errors.unsupportedFileType"));
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async (
event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) {
try {
const item = event.dataTransfer.items[0];
const handle: FileSystemHandle | null =
(await (item 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 = (buffer: ArrayBuffer) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
null;
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
// uint8 leading bytes
const headerBytes = {
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
png: "137 80 78 71 13 10 26 10 ",
// 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 ",
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
gif: "71 73 70 56 57 97 ",
};
if (first8Bytes === headerBytes.png) {
mimeType = MIME_TYPES.png;
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
mimeType = MIME_TYPES.jpg;
} else if (first8Bytes.startsWith(headerBytes.gif)) {
mimeType = MIME_TYPES.gif;
}
return mimeType;
};
export const createFile = (
blob: File | Blob | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
name: string | undefined,
) => {
return new File([blob], name || "", {
type: mimeType,
});
};
/** 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) => {
if (!file.type) {
if (file?.name?.endsWith(".excalidrawlib")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidrawlib,
file.name,
);
} else if (file?.name?.endsWith(".excalidraw")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidraw,
file.name,
);
} else {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType) {
file = createFile(buffer, mimeType, file.name);
}
}
// when the file is an image, make sure the extension corresponds to the
// actual mimeType (this is an edge case, but happens sometime)
} else if (isSupportedImageFile(file)) {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType && mimeType !== file.type) {
file = createFile(buffer, mimeType, file.name);
}
}
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,391 @@
import { deflate, inflate } from "pako";
import { encryptData, decryptData } from "./encryption";
// -----------------------------------------------------------------------------
// byte (binary) strings
// -----------------------------------------------------------------------------
// fast, Buffer-compatible implem
export const toByteString = (
data: string | Uint8Array | ArrayBuffer,
): Promise<string> => {
return new Promise((resolve, reject) => {
const blob =
typeof data === "string"
? new Blob([new TextEncoder().encode(data)])
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target || typeof event.target.result !== "string") {
return reject(new Error("couldn't convert to byte string"));
}
resolve(event.target.result);
};
reader.readAsBinaryString(blob);
});
};
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 = async (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
};
// async to align with stringToBase64
export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
};
// -----------------------------------------------------------------------------
// 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 = async ({
text,
compress,
}: {
text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean;
}): Promise<EncodedData> => {
let deflated!: string;
if (compress !== false) {
try {
deflated = await toByteString(deflate(text));
} catch (error: any) {
console.error("encode: cannot deflate", error);
}
}
return {
version: "1",
encoding: "bstring",
compressed: !!deflated,
encoded: deflated || (await toByteString(text)),
};
};
export const decode = async (data: EncodedData): Promise<string> => {
let decoded: string;
switch (data.encoding) {
case "bstring":
// if compressed, do not double decode the bstring
decoded = data.compressed
? data.encoded
: await 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[]) => {
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 | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data),
);
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> => {
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,
decryptedBuffer: Uint8Array,
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,93 @@
import { ENCRYPTION_KEY_BITS } from "../constants";
import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
export const createIV = () => {
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 | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array =
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 as ArrayBuffer | Uint8Array,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array,
encrypted: Uint8Array | 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,102 @@
import {
fileOpen as _fileOpen,
fileSave as _fileSave,
FileSystemHandle,
supported as nativeFileSystemSupported,
} from "browser-fs-access";
import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors";
import { debounce } from "../utils";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export const fileOpen = <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[]);
return _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}) as Promise<RetType>;
};
export const fileSave = (
blob: Blob,
opts: {
/** supply without the extension */
name: string;
/** file extension */
extension: FILE_EXTENSION;
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
},
) => {
return _fileSave(
blob,
{
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
},
opts.fileHandle,
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };

View file

@ -0,0 +1,122 @@
import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
// -----------------------------------------------------------------------------
// 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(
await 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 await decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};
// -----------------------------------------------------------------------------
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = async ({ text }: { text: string }) => {
const base64 = await stringToBase64(
JSON.stringify(await encode({ text })),
true /* is already byte string */,
);
let metadata = "";
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
metadata += `<!-- payload-version:2 -->`;
metadata += "<!-- payload-start -->";
metadata += base64;
metadata += "<!-- payload-end -->";
return metadata;
};
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
const version = versionMatch?.[1] || "1";
const isByteString = version !== "1";
try {
const json = await base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
if (
"type" in encodedData &&
encodedData.type === EXPORT_DATA_TYPES.excalidraw
) {
return json;
}
throw new Error("FAILED");
}
return await decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
}
}
throw new Error("INVALID");
};

View file

@ -0,0 +1,187 @@
import {
copyBlobToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n";
import { elementsOverlappingBBox } from "../../withinBounds";
import { isSomeElementSelected, getSelectedElements } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { cloneJSON } from "../utils";
import { canvasToBlob } from "./blob";
import { fileSave, FileSystemHandle } from "./filesystem";
import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
_brand: "exportedElements";
};
export const prepareElementsForExport = (
elements: readonly ExcalidrawElement[],
{ selectedElementIds }: Pick<AppState, "selectedElementIds">,
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const isExportingSelection =
exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameLikeElement | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
},
)
: elements;
if (isExportingSelection) {
if (
exportedElements.length === 1 &&
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({
elements,
bounds: exportingFrame,
type: "overlap",
});
} 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,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
name: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null;
},
) => {
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = await exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files,
{ exportingFrame },
);
if (type === "svg") {
return await fileSave(
new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
{
description: "Export to SVG",
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle,
},
);
} else if (type === "clipboard-svg") {
await copyTextToSystemClipboard(tempSvg.outerHTML);
return;
}
}
const tempCanvas = exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({
blob,
metadata: serializeAsJSON(elements, appState, files, "local"),
});
}
return await fileSave(blob, {
description: "Export to PNG",
name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "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 error;
}
// 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,153 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
import {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
/**
* 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: EXPORT_SOURCE,
elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(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 (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
const fileHandle = await fileSave(blob, {
name: appState.name,
extension: "excalidraw",
description: "Excalidraw file",
fileHandle: isImageFileHandle(appState.fileHandle)
? null
: appState.fileHandle,
});
return { fileHandle };
};
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(
await normalizeFile(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: EXPORT_SOURCE,
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,485 @@
import { loadLibraryFromBlob } from "./blob";
import {
LibraryItems,
LibraryItem,
ExcalidrawImperativeAPI,
LibraryItemsSource,
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
import { t } from "../i18n";
import { useEffect, useRef } from "react";
import {
URL_HASH_KEYS,
URL_QUERY_KEYS,
APP_NAME,
EVENT,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { cloneJSON } from "../utils";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
cloneJSON(libraryItems);
/**
* checks if library item does not exist already in current library
*/
const isUniqueItem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library {
/** latest libraryItems */
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private app: App;
constructor(app: App) {
this.app = app;
}
private updateQueue: Promise<LibraryItems>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
};
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
);
} catch (error) {
console.error(error);
}
}
};
/** call on excalidraw instance unmount */
destroy = () => {
this.isInitialized = false;
this.updateQueue = [];
this.lastLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
// status: "loading",
// isInitialized: false,
// libraryItems: [],
// });
};
resetLibrary = () => {
return this.setLibrary([]);
};
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
try {
const libraryItems = await (this.getLastUpdateTask() ||
this.lastLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
}
} catch (error) {
return resolve(this.lastLibraryItems);
}
});
};
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
// a slight overhead (always restoring library items). For internal use
// where merging isn't needed, use `library.setLibrary()` directly.
updateLibrary = async ({
libraryItems,
prompt = false,
merge = false,
openLibraryMenu = false,
defaultStatus = "unpublished",
}: {
libraryItems: LibraryItemsSource;
merge?: boolean;
prompt?: boolean;
openLibraryMenu?: boolean;
defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => {
if (openLibraryMenu) {
this.app.setState({
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
});
}
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
? libraryItems(this.lastLibraryItems)
: libraryItems);
let nextItems;
if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus);
} else {
nextItems = restoreLibraryItems(source, defaultStatus);
}
if (
!prompt ||
window.confirm(
t("alerts.confirmAddLibrary", {
numShapes: nextItems.length,
}),
)
) {
if (prompt) {
// focus container if we've prompted. We focus conditionally
// lest `props.autoFocus` is disabled (in which case we should
// focus only on user action such as prompt confirm)
this.app.focusContainer();
}
if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else {
resolve(nextItems);
}
} else {
reject(new AbortError());
}
} catch (error: any) {
reject(error);
}
});
});
};
setLibrary = (
/**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
};
}
export default Library;
export const distributeLibraryItemsOnSquareGrid = (
libraryItems: LibraryItems,
) => {
const PADDING = 50;
const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
const resElements: ExcalidrawElement[] = [];
const getMaxHeightPerRow = (row: number) => {
const maxHeight = libraryItems
.slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
.reduce((acc, item) => {
const { height } = getCommonBoundingBox(item.elements);
return Math.max(acc, height);
}, 0);
return maxHeight;
};
const getMaxWidthPerCol = (targetCol: number) => {
let index = 0;
let currCol = 0;
let maxWidth = 0;
for (const item of libraryItems) {
if (index % ITEMS_PER_ROW === 0) {
currCol = 0;
}
if (currCol === targetCol) {
const { width } = getCommonBoundingBox(item.elements);
maxWidth = Math.max(maxWidth, width);
}
index++;
currCol++;
}
return maxWidth;
};
let colOffsetX = 0;
let rowOffsetY = 0;
let maxHeightCurrRow = 0;
let maxWidthCurrCol = 0;
let index = 0;
let col = 0;
let row = 0;
for (const item of libraryItems) {
if (index && index % ITEMS_PER_ROW === 0) {
rowOffsetY += maxHeightCurrRow + PADDING;
colOffsetX = 0;
col = 0;
row++;
}
if (col === 0) {
maxHeightCurrRow = getMaxHeightPerRow(row);
}
maxWidthCurrCol = getMaxWidthPerCol(col);
const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
const offsetCenterX = (maxWidthCurrCol - width) / 2;
const offsetCenterY = (maxHeightCurrRow - height) / 2;
resElements.push(
// eslint-disable-next-line no-loop-func
...item.elements.map((element) => ({
...element,
x:
element.x +
// offset for column
colOffsetX +
// offset to center in given square grid
offsetCenterX -
// subtract minX so that given item starts at 0 coord
minX,
y:
element.y +
// offset for row
rowOffsetY +
// offset to center in given square grid
offsetCenterY -
// subtract minY so that given item starts at 0 coord
minY,
})),
);
colOffsetX += maxWidthCurrCol + PADDING;
index++;
col++;
}
return resElements;
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
new URLSearchParams(window.location.hash.slice(1)).get(
URL_HASH_KEYS.addLibrary,
) ||
// legacy, kept for compat reasons
new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
const idToken = libraryUrl
? new URLSearchParams(window.location.hash.slice(1)).get("token")
: null;
return libraryUrl ? { libraryUrl, idToken } : null;
};
export const useHandleLibrary = ({
excalidrawAPI,
getInitialLibraryItems,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
getInitialLibraryItems?: () => LibraryItemsSource;
}) => {
const getInitialLibraryRef = useRef(getInitialLibraryItems);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
const importLibraryFromURL = async ({
libraryUrl,
idToken,
}: {
libraryUrl: string;
idToken: string | null;
}) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
reject(error);
}
});
const shouldPrompt = idToken !== excalidrawAPI.id;
// wait for the tab to be focused before continuing in case we'll prompt
// for confirmation
await (shouldPrompt && document.hidden
? new Promise<void>((resolve) => {
window.addEventListener("focus", () => resolve(), {
once: true,
});
})
: null);
try {
await excalidrawAPI.updateLibrary({
libraryItems: libraryPromise,
prompt: shouldPrompt,
merge: true,
defaultStatus: "published",
openLibraryMenu: true,
});
} catch (error) {
throw error;
} finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
const hash = new URLSearchParams(window.location.hash.slice(1));
hash.delete(URL_HASH_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
const query = new URLSearchParams(window.location.search);
query.delete(URL_QUERY_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
}
}
};
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
event.stopImmediatePropagation();
// If hash changed and it contains library url, import it and replace
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
importLibraryFromURL(libraryUrlTokens);
}
};
// -------------------------------------------------------------------------
// ------ init load --------------------------------------------------------
if (getInitialLibraryRef.current) {
excalidrawAPI.updateLibrary({
libraryItems: getInitialLibraryRef.current(),
});
}
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
importLibraryFromURL(libraryUrlTokens);
}
// --------------------------------------------------------- init load -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
};
}, [excalidrawAPI]);
};

View file

@ -0,0 +1,104 @@
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = "light",
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}

View file

@ -0,0 +1,40 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles } from "../types";
import { exportCanvas, prepareElementsForExport } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
throw new Error(
"fileHandle should exist and should be of type svg or png when resaving",
);
}
appState = {
...appState,
exportEmbedScene: true,
};
const { exportedElements, exportingFrame } = prepareElementsForExport(
elements,
appState,
false,
);
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
});
return { fileHandle };
};

View file

@ -0,0 +1,640 @@
import {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
PointBinding,
StrokeRoundness,
} from "../element/types";
import {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import { ImportedDataState, LegacyAppState } from "./types";
import {
getNonDeletedElements,
getNormalizedDimensions,
isInvisiblySmallElement,
refreshTextDimensions,
} from "../element";
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import { randomId } from "../random";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
DEFAULT_SIDEBAR,
DEFAULT_ELEMENT_PROPS,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
>;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
> = {
selection: true,
text: true,
rectangle: true,
diamond: true,
ellipse: true,
line: true,
image: true,
arrow: true,
freedraw: true,
eraser: false,
custom: true,
frame: true,
embeddable: true,
hand: true,
laser: false,
magicframe: false,
};
export type RestoredDataState = {
elements: ExcalidrawElement[];
appState: RestoredAppState;
files: BinaryFiles;
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
return FONT_FAMILY[
fontFamilyName as keyof typeof FONT_FAMILY
] as FontFamilyValues;
}
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (binding: PointBinding | null) => {
if (!binding) {
return null;
}
return { ...binding, focus: binding.focus || 0 };
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string;
},
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: T,
extra: Pick<
T,
// This extra Pick<T, keyof K> ensure no excess properties are passed.
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
} = {
type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
versionNonce: element.versionNonce ?? 0,
isDeleted: element.isDeleted ?? false,
id: element.id || randomId(),
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
angle: element.angle || 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
backgroundColor:
element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness
? element.roundness
: element.strokeSharpness === "round"
? {
// for old elements that would now use adaptive radius algo,
// use legacy algo instead
type: isUsingAdaptiveRadius(element.type)
? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
boundElements: element.boundElementIds
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false,
};
if ("customData" in element || "customData" in extra) {
base.customData =
"customData" in extra ? extra.customData : element.customData;
}
if (PRECEDING_ELEMENT_KEY in element) {
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
}
return {
...base,
...getNormalizedDimensions(base),
...extra,
} as unknown as T;
};
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
refreshDimensions = false,
): typeof element | null => {
switch (element.type) {
case "text":
let fontSize = element.fontSize;
let fontFamily = element.fontFamily;
if ("font" in element) {
const [fontPx, _fontFamily]: [string, string] = (
element as any
).font.split(" ");
fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
const text = (typeof element.text === "string" && element.text) || "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
const lineHeight =
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
text,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null,
originalText: element.originalText || text,
lineHeight,
baseline,
});
// if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.)
if (!text && !element.isDeleted) {
element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element);
}
if (refreshDimensions) {
element = { ...element, ...refreshTextDimensions(element) };
}
return element;
case "freedraw": {
return restoreElementWithProperties(element, {
points: element.points,
lastCommittedPoint: null,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
});
}
case "image":
return restoreElementWithProperties(element, {
status: element.status || "pending",
fileId: element.fileId,
scale: element.scale || [1, 1],
});
case "line":
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
case "arrow": {
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
return restoreElementWithProperties(element, {
type:
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
x,
y,
});
}
// generic elements
case "ellipse":
case "rectangle":
case "diamond":
case "iframe":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: null,
});
case "magicframe":
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
// Don't use default case so as to catch a missing an element type case.
// We also don't want to throw, but instead return void so we filter
// out these unsupported elements from the restored array.
}
return null;
};
/**
* Repairs contaienr element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*
* NOTE mutates elements.
*/
const repairContainerElement = (
container: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (container.boundElements) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = container.boundElements.slice();
// dedupe bindings & fix boundElement.containerId if not set already
const boundIds = new Set<ExcalidrawElement["id"]>();
container.boundElements = boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const boundElement = elementsMap.get(binding.id);
if (boundElement && !boundIds.has(binding.id)) {
boundIds.add(binding.id);
if (boundElement.isDeleted) {
return acc;
}
acc.push(binding);
if (
isTextElement(boundElement) &&
// being slightly conservative here, preserving existing containerId
// if defined, lest boundElements is stale
!boundElement.containerId
) {
(boundElement as Mutable<ExcalidrawTextElement>).containerId =
container.id;
}
}
return acc;
},
[],
);
}
};
/**
* Repairs target bound element's container's boundElements array,
* or removes contaienrId if container does not exist.
*
* NOTE mutates elements.
*/
const repairBoundElement = (
boundElement: Mutable<ExcalidrawTextElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
const container = boundElement.containerId
? elementsMap.get(boundElement.containerId)
: null;
if (!container) {
boundElement.containerId = null;
return;
}
if (boundElement.isDeleted) {
return;
}
if (
container.boundElements &&
!container.boundElements.find((binding) => binding.id === boundElement.id)
) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = (
container.boundElements || (container.boundElements = [])
).slice();
boundElements.push({ type: "text", id: boundElement.id });
container.boundElements = boundElements;
}
};
/**
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!containingFrame) {
element.frameId = null;
}
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(
element,
opts?.refreshDimensions,
);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
}
return elements;
}, [] as ExcalidrawElement[]);
if (!opts?.repairBindings) {
return restoredElements;
}
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
repairContainerElement(element, restoredElementsMap);
}
}
return restoredElements;
};
const coalesceAppStateValue = <
T extends keyof ReturnType<typeof getDefaultAppState>,
>(
key: T,
appState: Exclude<ImportedDataState["appState"], null | undefined>,
defaultAppState: ReturnType<typeof getDefaultAppState>,
) => {
const value = appState[key];
// NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
return value !== undefined ? value! : defaultAppState[key];
};
const LegacyAppStateMigrations: {
[K in keyof LegacyAppState]: (
ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
defaultAppState: ReturnType<typeof getDefaultAppState>,
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
} = {
isSidebarDocked: (appState, defaultAppState) => {
return [
"defaultSidebarDockedPreference",
appState.isSidebarDocked ??
coalesceAppStateValue(
"defaultSidebarDockedPreference",
appState,
defaultAppState,
),
];
},
};
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState;
// first, migrate all legacy AppState properties to new ones. We do it
// in one go before migrate the rest of the properties in case the new ones
// depend on checking any other key (i.e. they are coupled)
for (const legacyKey of Object.keys(
LegacyAppStateMigrations,
) as (keyof typeof LegacyAppStateMigrations)[]) {
if (legacyKey in appState) {
const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
appState,
defaultAppState,
);
(nextAppState as any)[nextKey] = nextValue;
}
}
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState,
any,
][]) {
// if AppState contains a legacy key, prefer that one and migrate its
// value to the new one
const suppliedValue = appState[key];
const localValue = localAppState ? localAppState[key] : undefined;
(nextAppState as any)[key] =
suppliedValue !== undefined
? suppliedValue
: localValue !== undefined
? localValue
: defaultValue;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
// reset on fresh restore so as to hide the UI button if penMode not active
penDetected:
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool: {
...updateActiveTool(
defaultAppState,
nextAppState.activeTool.type &&
AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool
: { type: "selection" },
),
lastActiveTool: null,
locked: nextAppState.activeTool.locked ?? false,
},
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
? {
value: appState.zoom as NormalizedZoomValue,
}
: appState.zoom?.value
? appState.zoom
: defaultAppState.zoom,
openSidebar:
// string (legacy)
typeof (appState.openSidebar as any as string) === "string"
? { name: DEFAULT_SIDEBAR.name }
: nextAppState.openSidebar,
};
};
export const restore = (
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
/**
* Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not
* explicitly specified).
* Supply `null` if you can't get access to it.
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements, elementsConfig),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
};
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
null,
);
return elements.length ? { ...libraryItem, elements } : null;
};
export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"],
) => {
const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
} else {
const _item = item as MarkOptional<
LibraryItem,
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
}
}
return restoredItems;
};

View file

@ -0,0 +1,825 @@
import { vi } from "vitest";
import {
ExcalidrawElementSkeleton,
convertToExcalidrawElements,
} from "./transform";
import { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
describe("Test Transform", () => {
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
id: "rect-1",
},
];
let data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(data.length).toBe(1);
expect(data[0].id).toBe("id0");
data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(data[0].id).toBe("rect-1");
});
it("should transform regular shapes", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
},
{
type: "ellipse",
x: 100,
y: 250,
},
{
type: "diamond",
x: 100,
y: 400,
},
{
type: "rectangle",
x: 300,
y: 100,
width: 200,
height: 100,
backgroundColor: "#c0eb75",
strokeWidth: 2,
},
{
type: "ellipse",
x: 300,
y: 250,
width: 200,
height: 100,
backgroundColor: "#ffc9c9",
strokeStyle: "dotted",
fillStyle: "solid",
strokeWidth: 2,
},
{
type: "diamond",
x: 300,
y: 400,
width: 200,
height: 100,
backgroundColor: "#a5d8ff",
strokeColor: "#1971c2",
strokeStyle: "dashed",
fillStyle: "cross-hatch",
strokeWidth: 2,
},
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform text element", () => {
const elements = [
{
type: "text",
x: 100,
y: 100,
text: "HELLO WORLD!",
},
{
type: "text",
x: 100,
y: 150,
text: "STYLED HELLO WORLD!",
fontSize: 20,
strokeColor: "#5f3dc4",
},
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform linear elements", () => {
const elements = [
{
type: "arrow",
x: 100,
y: 20,
},
{
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
},
{
type: "line",
x: 100,
y: 60,
},
{
type: "line",
x: 450,
y: 60,
strokeColor: "#2f9e44",
strokeWidth: 2,
strokeStyle: "dotted",
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform to text containers when label provided", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
label: {
text: "RECTANGLE TEXT CONTAINER",
},
},
{
type: "ellipse",
x: 500,
y: 100,
width: 200,
label: {
text: "ELLIPSE TEXT CONTAINER",
},
},
{
type: "diamond",
x: 100,
y: 150,
width: 280,
label: {
text: "DIAMOND\nTEXT CONTAINER",
},
},
{
type: "diamond",
x: 100,
y: 400,
width: 300,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "STYLED DIAMOND TEXT CONTAINER",
strokeColor: "#099268",
fontSize: 20,
},
},
{
type: "rectangle",
x: 500,
y: 300,
width: 200,
strokeColor: "#c2255c",
label: {
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
textAlign: "left",
verticalAlign: "top",
fontSize: 20,
},
},
{
type: "ellipse",
x: 500,
y: 500,
strokeColor: "#f08c00",
backgroundColor: "#ffec99",
width: 200,
label: {
text: "STYLED ELLIPSE TEXT CONTAINER",
strokeColor: "#c2255c",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(12);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should transform to labelled arrows when label provided for arrows", () => {
const elements = [
{
type: "arrow",
x: 100,
y: 100,
label: {
text: "LABELED ARROW",
},
},
{
type: "arrow",
x: 100,
y: 200,
label: {
text: "STYLED LABELED ARROW",
strokeColor: "#099268",
fontSize: 20,
},
},
{
type: "arrow",
x: 100,
y: 300,
strokeColor: "#1098ad",
strokeWidth: 2,
label: {
text: "ANOTHER STYLED LABELLED ARROW",
},
},
{
type: "arrow",
x: 100,
y: 400,
strokeColor: "#1098ad",
strokeWidth: 2,
label: {
text: "ANOTHER STYLED LABELLED ARROW",
strokeColor: "#099268",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(8);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
describe("Test Frames", () => {
it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchObject({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should consider max of calculated and frame dimensions when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{
type: "frame",
children: ["1", "2"],
name: "My frame",
width: 800,
height: 100,
},
];
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800);
expect(frame.height).toBe(126);
});
});
describe("Test arrow bindings", () => {
it("should bind arrows to shapes when start / end provided without ids", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
type: "rectangle",
},
end: {
type: "ellipse",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text, rectangle, ellipse] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [{ id: text.id, type: "text" }],
startBinding: {
elementId: rectangle.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: ellipse.id,
focus: 0,
},
});
expect(text).toMatchObject({
x: 240,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
containerId: arrow.id,
});
expect(rectangle).toMatchObject({
x: 155,
y: 189,
type: "rectangle",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
expect(ellipse).toMatchObject({
x: 355,
y: 189,
type: "ellipse",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to text when start / end provided without ids", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
type: "text",
text: "HEYYYYY",
},
end: {
type: "text",
text: "WHATS UP ?",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [{ id: text1.id, type: "text" }],
startBinding: {
elementId: text2.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: text3.id,
focus: 0,
},
});
expect(text1).toMatchObject({
x: 240,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
containerId: arrow.id,
});
expect(text2).toMatchObject({
x: 185,
y: 226.5,
type: "text",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
expect(text3).toMatchObject({
x: 355,
y: 226.5,
type: "text",
boundElements: [
{
id: arrow.id,
type: "arrow",
},
],
});
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing shapes when start / end provided with ids", () => {
const elements = [
{
type: "ellipse",
id: "ellipse-1",
strokeColor: "#66a80f",
x: 630,
y: 316,
width: 300,
height: 300,
backgroundColor: "#d8f5a2",
},
{
type: "diamond",
id: "diamond-1",
strokeColor: "#9c36b5",
width: 140,
x: 96,
y: 400,
},
{
type: "arrow",
x: 247,
y: 420,
width: 395,
height: 35,
strokeColor: "#1864ab",
start: {
type: "rectangle",
width: 300,
height: 300,
},
end: {
id: "ellipse-1",
},
},
{
type: "arrow",
x: 227,
y: 450,
width: 400,
strokeColor: "#e67700",
start: {
id: "diamond-1",
},
end: {
id: "ellipse-1",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(5);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing text elements when start / end provided with ids", () => {
const elements = [
{
x: 100,
y: 239,
type: "text",
text: "HEYYYYY",
id: "text-1",
strokeColor: "#c2255c",
},
{
type: "text",
id: "text-2",
x: 560,
y: 239,
text: "Whats up ?",
},
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
id: "text-1",
},
end: {
id: "text-2",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
it("should bind arrows to existing elements if ids are correct", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementationOnce(() => void 0);
const elements = [
{
x: 100,
y: 239,
type: "text",
text: "HEYYYYY",
id: "text-1",
strokeColor: "#c2255c",
},
{
type: "rectangle",
x: 560,
y: 139,
id: "rect-1",
width: 100,
height: 200,
backgroundColor: "#bac8ff",
},
{
type: "arrow",
x: 255,
y: 239,
label: {
text: "HELLO WORLD!!",
},
start: {
id: "text-13",
},
end: {
id: "rect-11",
},
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [, , arrow, text] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [
{
id: text.id,
type: "text",
},
],
startBinding: null,
endBinding: null,
});
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
1,
"No element for start binding with id text-13 found",
);
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
2,
"No element for end binding with id rect-11 found",
);
});
it("should bind when ids referenced before the element data", () => {
const elements = [
{
type: "arrow",
x: 255,
y: 239,
end: {
id: "rect-1",
},
},
{
type: "rectangle",
x: 560,
y: 139,
id: "rect-1",
width: 100,
height: 200,
backgroundColor: "#bac8ff",
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
gap: 205,
});
expect(rect.boundElements).toStrictEqual([
{
id: arrow.id,
type: "arrow",
},
]);
});
});
it("should not allow duplicate ids", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementationOnce(() => void 0);
const elements = [
{
type: "rectangle",
x: 300,
y: 100,
id: "rect-1",
width: 100,
height: 200,
},
{
type: "rectangle",
x: 100,
y: 200,
id: "rect-1",
width: 100,
height: 200,
},
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(1);
expect(excaldrawElements[0]).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Duplicate id found for rect-1",
);
});
});

View file

@ -0,0 +1,746 @@
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
import {
getCommonBounds,
newElement,
newLinearElement,
redrawTextBoundingBox,
} from "../element";
import { bindLinearElement } from "../element/binding";
import {
ElementConstructorOpts,
newFrameElement,
newImageElement,
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import {
getDefaultLineHeight,
measureText,
normalizeText,
} from "../element/textElement";
import {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FileId,
FontFamilyValues,
TextAlign,
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, cloneJSON, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
y: number;
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
end?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<ExcalidrawTextElement>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
start?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<ExcalidrawTextElement>)
) &
MarkOptional<ElementConstructorOpts, "x" | "y">;
} & Partial<ExcalidrawLinearElement>;
export type ValidContainer =
| {
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
id?: ExcalidrawGenericElement["id"];
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
} & ElementConstructorOpts;
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
x: number;
y: number;
} & Partial<ExcalidrawLinearElement>)
| ValidContainer
| ValidLinearElement
| ({
type: "text";
text: string;
x: number;
y: number;
id?: ExcalidrawTextElement["id"];
} & Partial<ExcalidrawTextElement>)
| ({
type: Extract<ExcalidrawImageElement["type"], "image">;
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
height: 0,
};
const DEFAULT_DIMENSION = 100;
const bindTextToContainer = (
container: ExcalidrawElement,
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
) => {
const textElement: ExcalidrawTextElement = newTextElement({
x: 0,
y: 0,
textAlign: TEXT_ALIGN.CENTER,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
...textProps,
containerId: container.id,
strokeColor: textProps.strokeColor || container.strokeColor,
});
Object.assign(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
return [container, textElement] as const;
};
const bindLinearElementToElement = (
linearElement: ExcalidrawArrowElement,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
endBinding: linearElement.endBinding || null,
});
if (start) {
const width = start?.width ?? DEFAULT_DIMENSION;
const height = start?.height ?? DEFAULT_DIMENSION;
let existingElement;
if (start.id) {
existingElement = elementStore.getElement(start.id);
if (!existingElement) {
console.error(`No element for start binding with id ${start.id} found`);
}
}
const startX = start.x || linearElement.x - width;
const startY = start.y || linearElement.y - height / 2;
const startType = existingElement ? existingElement.type : start.type;
if (startType) {
if (startType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (start.type === "text") {
text = start.text;
}
if (!text) {
console.error(
`No text found for start binding text element for ${linearElement.id}`,
);
}
startBoundElement = newTextElement({
x: startX,
y: startY,
type: "text",
...existingElement,
...start,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(startBoundElement, {
x: start.x || linearElement.x - startBoundElement.width,
y: start.y || linearElement.y - startBoundElement.height / 2,
});
} else {
switch (startType) {
case "rectangle":
case "ellipse":
case "diamond": {
startBoundElement = newElement({
x: startX,
y: startY,
width,
height,
...existingElement,
...start,
type: startType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element start type "${start.type}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"start",
);
}
}
if (end) {
const height = end?.height ?? DEFAULT_DIMENSION;
const width = end?.width ?? DEFAULT_DIMENSION;
let existingElement;
if (end.id) {
existingElement = elementStore.getElement(end.id);
if (!existingElement) {
console.error(`No element for end binding with id ${end.id} found`);
}
}
const endX = end.x || linearElement.x + linearElement.width;
const endY = end.y || linearElement.y - height / 2;
const endType = existingElement ? existingElement.type : end.type;
if (endType) {
if (endType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (end.type === "text") {
text = end.text;
}
if (!text) {
console.error(
`No text found for end binding text element for ${linearElement.id}`,
);
}
endBoundElement = newTextElement({
x: endX,
y: endY,
type: "text",
...existingElement,
...end,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(endBoundElement, {
y: end.y || linearElement.y - endBoundElement.height / 2,
});
} else {
switch (endType) {
case "rectangle":
case "ellipse":
case "diamond": {
endBoundElement = newElement({
x: endX,
y: endY,
width,
height,
...existingElement,
...end,
type: endType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element end type "${endType}"`,
true,
);
}
}
}
bindLinearElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"end",
);
}
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
return {
linearElement,
startBoundElement,
endBoundElement,
};
};
class ElementStore {
excalidrawElements = new Map<string, ExcalidrawElement>();
add = (ele?: ExcalidrawElement) => {
if (!ele) {
return;
}
this.excalidrawElements.set(ele.id, ele);
};
getElements = () => {
return Array.from(this.excalidrawElements.values());
};
getElement = (id: string) => {
return this.excalidrawElements.get(id);
};
}
export const convertToExcalidrawElements = (
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => {
if (!elementsSkeleton) {
return [];
}
const elements = cloneJSON(elementsSkeleton);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
}
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond": {
const width =
element?.label?.text && element.width === undefined
? 0
: element?.width || DEFAULT_DIMENSION;
const height =
element?.label?.text && element.height === undefined
? 0
: element?.height || DEFAULT_DIMENSION;
excalidrawElement = newElement({
...element,
width,
height,
});
break;
}
case "line": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({
width,
height,
points: [
[0, 0],
[width, height],
],
...element,
});
break;
}
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({
width,
height,
endArrowhead: "arrow",
points: [
[0, 0],
[width, height],
],
...element,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight =
element?.lineHeight || getDefaultLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
normalizedText,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
excalidrawElement = newTextElement({
width: metrics.width,
height: metrics.height,
fontFamily,
fontSize,
...element,
});
break;
}
case "image": {
excalidrawElement = newImageElement({
width: element?.width || DEFAULT_DIMENSION,
height: element?.height || DEFAULT_DIMENSION,
...element,
});
break;
}
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw":
case "iframe":
case "embeddable": {
excalidrawElement = element;
break;
}
default: {
excalidrawElement = element;
assertNever(
element,
`Unhandled element type "${(element as any).type}"`,
true,
);
}
}
const existingElement = elementStore.getElement(excalidrawElement.id);
if (existingElement) {
console.error(`Duplicate id found for ${excalidrawElement.id}`);
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
// Add labels and arrow bindings
for (const [id, element] of elementsWithIds) {
const excalidrawElement = elementStore.getElement(id)!;
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond":
case "arrow": {
if (element.label?.text) {
let [container, text] = bindTextToContainer(
excalidrawElement,
element?.label,
);
elementStore.add(container);
elementStore.add(text);
if (container.type === "arrow") {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as ExcalidrawArrowElement,
originalStart,
originalEnd,
elementStore,
);
container = linearElement;
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
}
} else {
switch (element.type) {
case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
start,
end,
elementStore,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
break;
}
}
}
break;
}
}
}
// Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") {
continue;
}
const frame = elementStore.getElement(id);
if (!frame) {
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
}
const childrenElements: ExcalidrawElement[] = [];
element.children.forEach((id) => {
const newElementId = oldToNewElementIdMap.get(id);
if (!newElementId) {
throw new Error(`Element with ${id} wasn't mapped correctly`);
}
const elementInFrame = elementStore.getElement(newElementId);
if (!elementInFrame) {
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
}
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
);
}
Object.assign(ele, { frameId: frame.id });
childrenElements.push(ele);
});
childrenElements.push(elementInFrame);
});
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
const PADDING = 10;
minX = minX - PADDING;
minY = minY - PADDING;
maxX = maxX + PADDING;
maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher
const width = Math.max(frame?.width, maxX - minX);
const height = Math.max(frame?.height, maxY - minY);
Object.assign(frame, { x: minX, y: minY, width, height });
}
return elementStore.getElements();
};

View file

@ -0,0 +1,59 @@
import { ExcalidrawElement } from "../element/types";
import {
AppState,
BinaryFiles,
LibraryItems,
LibraryItems_anyVersion,
} from "../types";
import type { cleanAppStateForExport } from "../appState";
import { VERSIONS } from "../constants";
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;
}

View file

@ -0,0 +1,30 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});

View file

@ -0,0 +1,35 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
/**
* Returns URL sanitized and safe for usage in places such as
* iframe's src attribute or <a> href attributes.
*/
export const toValidURL = (link: string) => {
link = normalizeLink(link);
// make relative links into fully-qualified urls
if (link.startsWith("/")) {
return `${location.origin}${link}`;
}
try {
new URL(link);
} catch {
// if link does not parse as URL, assume invalid and return blank page
return "about:blank";
}
return link;
};