Compare commits

..

34 Commits

Author SHA1 Message Date
Ryan Di 9954e5f3c2 use crop hash in svg export to fix multi instance crop error 2024-10-22 17:16:19 +08:00
Ryan Di 0cc70d07c7 add test for ratio cropping 2024-10-15 17:34:53 +08:00
Ryan Di 359a2f23ae remove rounding 2024-10-15 16:34:48 +08:00
Ryan Di 467a4a2a6a shift to crop with initial aspect ratio 2024-10-15 16:23:00 +08:00
Ryan Di a7b55da49e remove todo 2024-10-15 14:09:03 +08:00
Ryan Di 4f82c32861 fix image hit and increase mini size to 10 2024-10-11 17:58:03 +08:00
dwelle 8701a5f843 fix: actionCropEditor not working 2024-10-10 23:58:52 +02:00
dwelle c498247e0f fix: undo/redo 2024-10-10 23:58:28 +02:00
Ryan Di 81e1d61d42 demo: image selection with no padding 2024-10-09 19:44:08 +08:00
Ryan Di 741c8dec31 apply projection to angle 0 drag move as well 2024-10-09 19:17:57 +08:00
Ryan Di da0481683a change cropElement to id to work with undo/redo 2024-10-09 19:07:40 +08:00
Ryan Di 58307a96dc normalize before projection to improve rotated crop region drag 2024-10-09 18:16:09 +08:00
Ryan Di 1a0755cd56 add shortcut to context menu 2024-10-09 17:53:53 +08:00
Ryan Di c742b4847f center icon 2024-10-09 17:46:08 +08:00
Ryan Di bcb50188b8 add diagram link 2024-10-09 14:54:18 +08:00
Ryan Di bdb1ec387d add missing type 2024-10-09 13:33:22 +08:00
Ryan Di b0375fe5db refactor to simplify tests 2024-10-09 13:28:17 +08:00
Ryan Di 940099f85d lint 2024-10-09 13:27:45 +08:00
Ryan Di e30fd9960d account for rotation when moving the crop region 2024-10-08 12:04:51 +08:00
Ryan Di 80ff1562b8 improve discoverability 2024-10-07 21:21:06 +08:00
Ryan Di 3f00762a77 svg export 2024-10-07 17:56:37 +08:00
Ryan Di 7b012b1cad merge with master 2024-10-04 17:08:36 +08:00
Ryan Di a02c4cb140 adjust crop handles 2024-10-04 16:50:42 +08:00
Ryan Di 3a01122093 make cropping work with flipping 2024-10-04 14:27:13 +08:00
Ryan Di bccd2bf30d simplify properties further 2024-09-30 16:11:00 +08:00
Ryan Di 50e4a0b37d set a min width and height for crop 2024-09-25 16:04:18 +08:00
Ryan Di 9e80c9e3dd do not resize when in crop mode 2024-09-25 15:59:00 +08:00
Ryan Di afdf38e47c init width and height when dragging to create img 2024-09-25 15:38:01 +08:00
Ryan Di 06a7a51baa simplify app and pointer down state 2024-09-25 15:33:19 +08:00
Ryan Di 064bede0c5 simplify crop properties 2024-09-24 15:59:09 +08:00
Ryan Di 997fec6c75 manipulate and update a crop 2024-09-21 14:09:47 +08:00
Ryan Di 71ed96eabb render crop 2024-09-21 14:09:23 +08:00
Ryan Di f4bebaaa50 add properties for crop 2024-09-21 14:07:09 +08:00
Ryan Di 3d92a3bf1f double click to start cropping 2024-09-09 18:34:13 +08:00
375 changed files with 2766 additions and 20320 deletions
+1 -12
View File
@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
@@ -37,14 +37,3 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
# Enable PWA in dev server
VITE_APP_ENABLE_PWA=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
-15
View File
@@ -15,18 +15,3 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
PQIDAQAB'
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
VITE_APP_COLLAPSE_OVERLAY=false
# Enable eslint in dev server
VITE_APP_ENABLE_ESLINT=false
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img
src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
alt="Excalidraw logo: Sketch handrawn like diagrams."
/>
### Center
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description |
| ----------- | ---------------------- |
| `Virgil` | The `Hand-drawn` font |
| `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font |
+29 -41
View File
@@ -369,12 +369,10 @@ export default function ExampleApp({
return false;
}
await exportToClipboard({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
},
type: "json",
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
type,
});
window.alert(`Copied to clipboard as ${type} successfully`);
};
@@ -819,17 +817,15 @@ export default function ExampleApp({
return;
}
const svg = await exportToSvg({
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
exportEmbedScene,
width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
});
appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML;
@@ -845,18 +841,14 @@ export default function ExampleApp({
return;
}
const blob = await exportToBlob({
data: {
elements: excalidrawAPI?.getSceneElements(),
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
},
config: {
mimeType: "image/png",
elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png",
appState: {
...initialData.appState,
exportEmbedScene,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
});
setBlobUrl(window.URL.createObjectURL(blob));
}}
@@ -872,14 +864,12 @@ export default function ExampleApp({
return;
}
const canvas = await exportToCanvas({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
@@ -895,14 +885,12 @@ export default function ExampleApp({
return;
}
const canvas = await exportToCanvas({
data: {
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# copied assets
public/**/*.woff2
public/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@@ -1,2 +1,2 @@
# copied assets
public/**/*.woff2
public/*.woff2
@@ -13,7 +13,7 @@
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002"
-359
View File
@@ -25,13 +25,7 @@ import {
TTDDialogTrigger,
StoreAction,
reconcileElements,
exportToCanvas,
exportToSvg,
} from "../packages/excalidraw";
import {
exportToBlob,
getNonDeletedElements,
} from "../packages/excalidraw/index";
import type {
AppState,
ExcalidrawImperativeAPI,
@@ -132,10 +126,6 @@ import DebugCanvas, {
loadSavedDebugState,
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { fileSave } from "../packages/excalidraw/data/filesystem";
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
import { round } from "../packages/math";
polyfill();
@@ -616,19 +606,6 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
const canvasPreviewContainerRef = useRef<HTMLDivElement>(null);
const svgPreviewContainerRef = useRef<HTMLDivElement>(null);
const [config, setConfig] = useState<ExportSceneConfig>({
scale: 1,
position: "center",
fit: "contain",
});
useEffect(() => {
localStorage.setItem("_exportConfig", JSON.stringify(config));
}, [config]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
@@ -638,84 +615,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
const nonDeletedElements = getNonDeletedElements(elements);
const frame = nonDeletedElements.find(
(el) => el.strokeStyle === "dashed" && el.type === "rectangle",
);
exportToCanvas({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
// .concat(
// restoreElements(
// [
// // @ts-ignore
// {
// type: "rectangle",
// width: appState.width / zoom,
// height: appState.height / zoom,
// x: -appState.scrollX,
// y: -appState.scrollY,
// fillStyle: "solid",
// strokeColor: "transparent",
// backgroundColor: "rgba(0,0,0,0.05)",
// roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 40 },
// },
// ],
// null,
// ),
// ),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((canvas) => {
if (canvasPreviewContainerRef.current) {
canvasPreviewContainerRef.current.replaceChildren(canvas);
document.querySelector(
".canvas_dims",
)!.innerHTML = `${canvas.width}x${canvas.height} (canvas)`;
}
});
exportToSvg({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((svg) => {
if (svgPreviewContainerRef.current) {
svgPreviewContainerRef.current.replaceChildren(svg);
document.querySelector(".svg_dims")!.innerHTML = `${round(
parseFloat(svg.getAttribute("width") ?? ""),
0,
)}x${round(parseFloat(svg.getAttribute("height") ?? ""), 0)} (svg)`;
}
});
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
@@ -1221,269 +1120,11 @@ const ExcalidrawWrapper = () => {
/>
)}
</Excalidraw>
<div
style={{
display: "flex",
flexDirection: "column",
position: "fixed",
bottom: 60,
right: 60,
zIndex: 9999999999,
color: "black",
}}
>
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<label>
center{" "}
<input
type="checkbox"
checked={config.position === "center"}
onChange={() =>
setConfig((s) => ({
...s,
position: s.position === "center" ? "topLeft" : "center",
}))
}
/>
</label>
<label>
fit{" "}
<select
value={config.fit}
onChange={(event) =>
setConfig((s) => ({
...s,
fit: event.target.value as any,
}))
}
>
<option value="none">none</option>
<option value="contain">contain</option>
</select>
</label>
<label>
padding{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.padding}
onChange={(event) =>
setConfig((s) => ({
...s,
padding: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
scale{" "}
<input
type="number"
max={4}
style={{ width: "3rem" }}
value={config.scale}
onChange={(event) =>
setConfig((s) => ({
...s,
scale: !event.target.value.trim()
? undefined
: Math.min(parseFloat(event.target.value as any), 4),
}))
}
/>
</label>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
width{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.width}
onChange={(event) =>
setConfig((s) => ({
...s,
width: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
height{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.height}
onChange={(event) =>
setConfig((s) => ({
...s,
height: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
x{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.x}
onChange={(event) =>
setConfig((s) => ({
...s,
x: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label>
y{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.y}
onChange={(event) =>
setConfig((s) => ({
...s,
y: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label
style={{
opacity: config.widthOrHeight != null ? 0.5 : undefined,
}}
>
maxWH{" "}
<input
type="number"
// max={600}
style={{ width: "3rem" }}
value={config.maxWidthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
maxWidthOrHeight: !event.target.value.trim()
? undefined
: parseInt(event.target.value as any),
}))
}
/>
</label>
<label>
widthOrHeight{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.widthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
widthOrHeight: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
</div>
</div>
<div className="canvas_dims">0x0</div>
<div
ref={canvasPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
<div className="svg_dims">0x0</div>
<div
ref={svgPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
</div>
</div>
);
};
const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}>
@@ -1,222 +0,0 @@
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types";
import type { AppState, BinaryFileData } from "../packages/excalidraw/types";
import { ExcalidrawError } from "../packages/excalidraw/errors";
import { base64urlToString } from "../packages/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
// -----------------------------------------------------------------------------
// outgoing message
// -----------------------------------------------------------------------------
type MESSAGE_REQUEST_SCENE = {
type: "REQUEST_SCENE";
jwt: string;
};
type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
// incoming messages
// -----------------------------------------------------------------------------
type MESSAGE_READY = { type: "READY" };
type MESSAGE_ERROR = { type: "ERROR"; message: string };
type MESSAGE_SCENE_DATA = {
type: "SCENE_DATA";
elements: OrderedExcalidrawElement[];
appState: Pick<AppState, "viewBackgroundColor">;
files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
};
type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
// -----------------------------------------------------------------------------
const parseSceneData = async ({
rawElementsString,
rawAppStateString,
}: {
rawElementsString: string | null;
rawAppStateString: string | null;
}): Promise<MESSAGE_SCENE_DATA> => {
if (!rawElementsString || !rawAppStateString) {
throw new ExcalidrawError("Elements or appstate is missing.");
}
try {
const elements = JSON.parse(
rawElementsString,
) as OrderedExcalidrawElement[];
if (!elements.length) {
throw new ExcalidrawError("Scene is empty, nothing to export.");
}
const appState = JSON.parse(rawAppStateString) as Pick<
AppState,
"viewBackgroundColor"
>;
const fileIds = elements.reduce((acc, el) => {
if ("fileId" in el && el.fileId) {
acc.push(el.fileId);
}
return acc;
}, [] as FileId[]);
const files = await LocalData.fileStorage.getFiles(fileIds);
return {
type: "SCENE_DATA",
elements,
appState,
files,
};
} catch (error: any) {
throw error instanceof ExcalidrawError
? error
: new ExcalidrawError("Failed to parse scene data.");
}
};
const verifyJWT = async ({
token,
publicKey,
}: {
token: string;
publicKey: string;
}) => {
try {
if (!publicKey) {
throw new ExcalidrawError("Public key is undefined");
}
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new ExcalidrawError("Invalid JWT format");
}
// JWT is using Base64URL encoding
const decodedPayload = base64urlToString(payload);
const decodedSignature = base64urlToString(signature);
const data = `${header}.${payload}`;
const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
c.charCodeAt(0),
);
const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
c.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"spki",
keyArrayBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"],
);
const isValid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
signatureArrayBuffer,
new TextEncoder().encode(data),
);
if (!isValid) {
throw new Error("Invalid JWT");
}
const parsedPayload = JSON.parse(decodedPayload);
// Check for expiration
const currentTime = Math.floor(Date.now() / 1000);
if (parsedPayload.exp && parsedPayload.exp < currentTime) {
throw new Error("JWT has expired");
}
} catch (error) {
console.error("Failed to verify JWT:", error);
throw new Error(error instanceof Error ? error.message : "Invalid JWT");
}
};
export const ExcalidrawPlusIframeExport = () => {
const readyRef = useRef(false);
useLayoutEffect(() => {
const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
throw new ExcalidrawError("Invalid origin");
}
if (event.data.type === EVENT_REQUEST_SCENE) {
if (!event.data.jwt) {
throw new ExcalidrawError("JWT is missing");
}
try {
try {
await verifyJWT({
token: event.data.jwt,
publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
});
} catch (error: any) {
console.error(`Failed to verify JWT: ${error.message}`);
throw new ExcalidrawError("Failed to verify JWT");
}
const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
rawAppStateString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
),
rawElementsString: localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
),
});
event.source!.postMessage(parsedSceneData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
} catch (error) {
const responseData: MESSAGE_ERROR = {
type: "ERROR",
message:
error instanceof ExcalidrawError
? error.message
: "Failed to export scene data",
};
event.source!.postMessage(responseData, {
targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
});
}
}
};
window.addEventListener("message", handleMessage);
// so we don't send twice in StrictMode
if (!readyRef.current) {
readyRef.current = true;
const message: MESSAGE_FROM_EDITOR = { type: "READY" };
window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
}
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
// Since this component is expected to run in a hidden iframe on Excaildraw+,
// it doesn't need to render anything. All the data we need is available in
// LocalStorage and IndexedDB. It only needs to handle the messaging between
// the parent window and the iframe with the relevant data.
return null;
};
+2 -27
View File
@@ -1,7 +1,6 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
@@ -10,7 +9,6 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
@@ -159,7 +157,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError();
}
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
@@ -167,29 +165,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES,
}),
});
return {
savedFiles: savedFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
erroredFiles: erroredFiles.reduce(
(acc: Map<FileId, BinaryFileData>, id) => {
const fileData = addedFiles.get(id);
if (fileData) {
acc.set(id, fileData);
}
return acc;
},
new Map(),
),
};
},
});
this.excalidrawAPI = props.excalidrawAPI;
@@ -419,7 +394,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileTracked(element.fileId) &&
!this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted &&
(opts.forceFetchFiles
? element.status !== "pending" ||
+8 -12
View File
@@ -21,19 +21,15 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({
data: {
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
elements: children,
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
+1 -1
View File
@@ -84,7 +84,7 @@ const _debugRenderer = (
scale,
normalizedWidth,
normalizedHeight,
canvasBackgroundColor: "transparent",
viewBackgroundColor: "transparent",
});
// Apply zoom
+21 -50
View File
@@ -16,26 +16,14 @@ import type {
BinaryFiles,
} from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager {
/** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */
private savingFiles = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
/* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
private erroredFiles_save = new Map<
ExcalidrawImageElement["fileId"],
FileVersion
>();
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private _getFiles;
private _saveFiles;
@@ -49,8 +37,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>;
}>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
}) {
this._getFiles = getFiles;
@@ -58,28 +46,19 @@ export class FileManager {
}
/**
* returns whether file is saved/errored, or being processed
* returns whether file is already saved or being processed
*/
isFileTracked = (id: FileId) => {
isFileHandled = (id: FileId) => {
return (
this.savedFiles.has(id) ||
this.savingFiles.has(id) ||
this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) ||
this.erroredFiles_save.has(id)
this.savingFiles.has(id) ||
this.erroredFiles.has(id)
);
};
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
const fileVersion = this.getFileVersion(file);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
isFileSaved = (id: FileId) => {
return this.savedFiles.has(id);
};
saveFiles = async ({
@@ -92,16 +71,13 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (
fileData &&
// NOTE if errored during save, won't retry due to this check
!this.isFileSavedOrBeingSaved(fileData)
isInitializedImageElement(element) &&
files[element.fileId] &&
!this.isFileHandled(element.fileId)
) {
addedFiles.set(element.fileId, files[element.fileId]);
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
this.savingFiles.set(element.fileId, true);
}
}
@@ -110,12 +86,8 @@ export class FileManager {
addedFiles,
});
for (const [fileId, fileData] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData));
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, true);
}
return {
@@ -149,10 +121,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) {
this.savedFiles.set(file.id, this.getFileVersion(file));
this.savedFiles.set(file.id, true);
}
for (const [fileId] of erroredFiles) {
this.erroredFiles_fetch.set(fileId, true);
this.erroredFiles.set(fileId, true);
}
return { loadedFiles, erroredFiles };
@@ -188,7 +160,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => {
return (
isInitializedImageElement(element) &&
this.savedFiles.has(element.fileId) &&
this.isFileSaved(element.fileId) &&
element.status === "pending"
);
};
@@ -197,8 +169,7 @@ export class FileManager {
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
this.erroredFiles_fetch.clear();
this.erroredFiles_save.clear();
this.erroredFiles.clear();
}
}
+4 -4
View File
@@ -183,8 +183,8 @@ export class LocalData {
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, BinaryFileData>();
const erroredFiles = new Map<FileId, BinaryFileData>();
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
@@ -195,10 +195,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, fileData);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, fileData);
erroredFiles.set(id, true);
}
}),
);
+4 -4
View File
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
}) => {
const firebase = await loadFirebaseStorage();
const erroredFiles: FileId[] = [];
const savedFiles: FileId[] = [];
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
await Promise.all(
files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
},
);
savedFiles.push(id);
savedFiles.set(id, true);
} catch (error: any) {
erroredFiles.push(id);
erroredFiles.set(id, true);
}
}),
);
+13 -9
View File
@@ -54,6 +54,12 @@
content="https://excalidraw.com/og-image-3.png"
/>
<!-- General tags -->
<meta
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<!------------------------------------------------------------------------->
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
@@ -118,19 +124,19 @@
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/fonts.css"
type="text/css"
/>
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<% } %>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -212,7 +218,6 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@@ -245,6 +250,5 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+7 -10
View File
@@ -23,20 +23,20 @@
]
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": ">=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
@@ -49,8 +49,5 @@
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}
-3
View File
@@ -29,9 +29,6 @@ interface ImportMetaEnv {
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string;
+198 -218
View File
@@ -5,237 +5,217 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
export default defineConfig(({ mode }) => {
// To load .env variables
const envVars = loadEnv(mode, `../`);
// https://vitejs.dev/config/
return {
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
const family = chunkInfo.name.split("-")[0];
return `fonts/${family}/[name][extname]`;
}
// To load .env.local variables
const envVars = loadEnv("", `../`);
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir
envDir: "../",
build: {
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
}
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
return "assets/[name]-[hash][extname]";
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
manualChunks(id) {
if (
id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null
) {
const index = id.indexOf("locales/");
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
},
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
},
plugins: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: false,
},
workbox: {
// don't precache fonts, locales and separate chunks
globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [
{
urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
},
workbox: {
// Don't push fonts, locales and wasm to app precache
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
runtimeCaching: [
{
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
handler: "CacheFirst",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
{
urlPattern: new RegExp("fonts.css"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "fonts",
expiration: {
maxEntries: 50,
},
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
urlPattern: new RegExp("locales/[^/]+.js"),
handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
urlPattern: new RegExp(".wasm-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "wasm",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
},
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
],
},
manifest: {
short_name: "Excalidraw",
name: "Excalidraw",
description:
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
icons: [
{
src: "android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "apple-touch-icon.png",
type: "image/png",
sizes: "180x180",
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
start_url: "/",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
file_handlers: [
{
action: "/",
accept: {
"application/vnd.excalidraw+json": [".excalidraw"],
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
],
share_target: {
action: "/web-share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "file",
accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
};
screenshots: [
{
src: "/screenshots/virtual-whiteboard.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/wireframe.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/illustration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/shapes.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/collaboration.png",
type: "image/png",
sizes: "462x945",
},
{
src: "/screenshots/export.png",
type: "image/png",
sizes: "462x945",
},
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
});
+10 -10
View File
@@ -45,7 +45,7 @@
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": "18.0.0 - 20.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
@@ -55,9 +55,15 @@
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -68,15 +74,9 @@
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
-2
View File
@@ -15,8 +15,6 @@ Please add the latest change on the top under the correct section.
### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
+13 -35
View File
@@ -9,9 +9,8 @@ import {
readSystemClipboard,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportAsImage } from "../data/index";
import { exportCanvas, prepareElementsForExport } from "../data/index";
import { getTextFromElements, isTextElement } from "../element";
import { prepareElementsForExport } from "../data/index";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -137,41 +136,25 @@ export const actionCopyAsSvg = register({
);
try {
await exportAsImage({
type: "clipboard-svg",
data: { elements: exportedElements, appState, files: app.files },
config: {
await exportCanvas(
"clipboard-svg",
exportedElements,
appState,
app.files,
{
...appState,
exportingFrame,
name: app.getName(),
},
});
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
);
return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
@@ -207,16 +190,11 @@ export const actionCopyAsPng = register({
true,
);
try {
await exportAsImage({
type: "clipboard",
data: { elements: exportedElements, appState, files: app.files },
config: {
...appState,
exportingFrame,
name: appState.name || app.getName(),
},
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
});
return {
appState: {
...appState,
+2 -14
View File
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
import { getCanvasSize } from "../scene/export";
import { getExportSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
import type { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
@@ -58,18 +58,6 @@ export const actionChangeExportScale = register({
? getSelectedElements(elements, appState)
: elements;
const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[],
padding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements).map((dimension) =>
Math.trunc(dimension * scale),
);
return [width + padding * 2, height + padding * 2];
};
return (
<>
{EXPORT_SCALES.map((s) => {
@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types";
import { KEYS, matchKey } from "../keys";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
@@ -63,7 +63,9 @@ export const createUndoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
@@ -102,8 +104,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
+2
View File
@@ -23,6 +23,7 @@ export type ShortcutName =
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "gridMode"
@@ -87,6 +88,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
+5 -1
View File
@@ -10,6 +10,7 @@ import type {
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource =
@@ -23,7 +24,10 @@ export type ActionSource =
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null;
appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
replaceFiles?: boolean;
+4 -5
View File
@@ -1,18 +1,17 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
COLOR_WHITE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
DEFAULT_ZOOM_VALUE,
EXPORT_SCALES,
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
} from "./constants";
import type { AppState } from "./types";
import type { AppState, NormalizedZoomValue } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
@@ -100,10 +99,10 @@ export const getDefaultAppState = (): Omit<
editingFrame: null,
elementsToHighlight: null,
toast: null,
viewBackgroundColor: COLOR_WHITE,
viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: DEFAULT_ZOOM_VALUE,
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
pendingImageElementId: null,
+7 -4
View File
@@ -1,8 +1,11 @@
import type { Radians } from "../math";
import { pointFrom } from "../math";
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors";
import {
COLOR_CHARCOAL_BLACK,
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
VERTICAL_ALIGN,
@@ -170,7 +173,7 @@ const commonProps = {
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: COLOR_CHARCOAL_BLACK,
strokeColor: COLOR_PALETTE.black,
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
@@ -321,7 +324,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: COLOR_CHARCOAL_BLACK,
strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
+23 -21
View File
@@ -1,25 +1,27 @@
import oc from "open-color";
import {
COLOR_WHITE,
COLOR_CHARCOAL_BLACK,
COLOR_TRANSPARENT,
} from "./constants";
import { type Merge } from "./utility-types";
import { pick } from "./utils";
import type { Merge } from "./utility-types";
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,
keys: K,
) => {
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime" | "black">
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "charcoal"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{
charcoal: typeof COLOR_CHARCOAL_BLACK;
white: typeof COLOR_WHITE;
transparent: typeof COLOR_TRANSPARENT;
}
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors
@@ -39,7 +41,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "charcoal" | "black" | "white" | "bronze"
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
@@ -47,9 +49,9 @@ export const getSpecificColorShades = (
};
export const COLOR_PALETTE = {
transparent: COLOR_TRANSPARENT,
charcoal: COLOR_CHARCOAL_BLACK,
white: COLOR_WHITE,
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
@@ -85,7 +87,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.charcoal,
COLOR_PALETTE.black,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
@@ -123,7 +125,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
// rest
...COMMON_ELEMENT_SHADES,
@@ -134,7 +136,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal,
black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES,
+62 -132
View File
@@ -49,7 +49,7 @@ import {
} from "../appState";
import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants";
import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
import {
APP_NAME,
CURSOR_TYPE,
@@ -90,7 +90,7 @@ import {
DEFAULT_TEXT_ALIGN,
} from "../constants";
import type { ExportedElements } from "../data";
import { exportAsImage, loadFromBlob } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import {
@@ -304,10 +304,8 @@ import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import {
dataURLToFile,
dataURLToString,
generateIdFromFile,
getDataURL,
getDataURL_sync,
getFileFromEvent,
ImageURLToFile,
isImageFileHandle,
@@ -340,6 +338,7 @@ import {
isValidTextContainer,
measureText,
normalizeText,
wrapText,
} from "../element/textElement";
import {
showHyperlinkTooltip,
@@ -460,7 +459,6 @@ import {
vectorNormalize,
} from "../../math";
import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -752,7 +750,7 @@ class App extends React.Component<AppProps, AppState> {
id: this.id,
};
this.fonts = new Fonts(this.scene);
this.fonts = new Fonts({ scene: this.scene });
this.history = new History();
this.actionManager.registerAll(actions);
@@ -1815,20 +1813,18 @@ class App extends React.Component<AppProps, AppState> {
opts: { exportingFrame: ExcalidrawFrameLikeElement | null },
) => {
trackEvent("export", type, "ui");
const fileHandle = await exportAsImage({
const fileHandle = await exportCanvas(
type,
data: {
elements,
appState: this.state,
files: this.files,
},
config: {
elements,
this.state,
this.files,
{
exportBackground: this.state.exportBackground,
name: this.getName(),
viewBackgroundColor: this.state.viewBackgroundColor,
exportingFrame: opts.exportingFrame,
},
})
)
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
@@ -2126,7 +2122,9 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.files) {
this.addMissingFiles(actionResult.files, actionResult.replaceFiles);
this.files = actionResult.replaceFiles
? actionResult.files
: { ...this.files, ...actionResult.files };
this.addNewImagesToImageCache();
}
@@ -2166,12 +2164,11 @@ class App extends React.Component<AppProps, AppState> {
editingTextElement = null;
}
this.setState((prevAppState) => {
const actionAppState = actionResult.appState || {};
return {
...prevAppState,
...actionAppState,
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
@@ -2182,7 +2179,7 @@ class App extends React.Component<AppProps, AppState> {
theme,
name,
errorMessage,
};
});
});
didUpdate = true;
@@ -2322,11 +2319,11 @@ class App extends React.Component<AppProps, AppState> {
// clear the shape and image cache so that any images in initialData
// can be loaded fresh
this.clearImageShapeCache();
// manually loading the font faces seems faster even in browsers that do fire the loadingdone event
this.fonts.loadSceneFonts().then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadSceneFonts();
};
private isMobileBreakpoint = (width: number, height: number) => {
@@ -2487,7 +2484,7 @@ class App extends React.Component<AppProps, AppState> {
this.renderer.destroy();
this.scene.destroy();
this.scene = new Scene();
this.fonts = new Fonts(this.scene);
this.fonts = new Fonts({ scene: this.scene });
this.renderer = new Renderer(this.scene);
this.files = {};
this.imageCache.clear();
@@ -2559,27 +2556,19 @@ class App extends React.Component<AppProps, AppState> {
{ passive: false },
),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
passive: false,
}), // #3553
addEventListener(document, EVENT.COPY, this.onCopy, { passive: false }),
addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553
addEventListener(document, EVENT.COPY, this.onCopy),
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
addEventListener(
document,
EVENT.POINTER_MOVE,
this.updateCurrentCursorPosition,
{ passive: false },
),
// rerender text elements on font load to fix #637 && #1553
addEventListener(
document.fonts,
"loadingdone",
(event) => {
const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onLoaded(fontFaces);
},
{ passive: false },
),
addEventListener(document.fonts, "loadingdone", (event) => {
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onLoaded(loadedFontFaces);
}),
// Safari-only desktop pinch zoom
addEventListener(
document,
@@ -2599,17 +2588,12 @@ class App extends React.Component<AppProps, AppState> {
this.onGestureEnd as any,
false,
),
addEventListener(
window,
EVENT.FOCUS,
() => {
this.maybeCleanupAfterMissingPointerUp(null);
// browsers (chrome?) tend to free up memory a lot, which results
// in canvas context being cleared. Thus re-render on focus.
this.triggerRender(true);
},
{ passive: false },
),
addEventListener(window, EVENT.FOCUS, () => {
this.maybeCleanupAfterMissingPointerUp(null);
// browsers (chrome?) tend to free up memory a lot, which results
// in canvas context being cleared. Thus re-render on focus.
this.triggerRender(true);
}),
);
if (this.state.viewModeEnabled) {
@@ -2625,12 +2609,9 @@ class App extends React.Component<AppProps, AppState> {
document,
EVENT.FULLSCREENCHANGE,
this.onFullscreenChange,
{ passive: false },
),
addEventListener(document, EVENT.PASTE, this.pasteFromClipboard, {
passive: false,
}),
addEventListener(document, EVENT.CUT, this.onCut, { passive: false }),
addEventListener(document, EVENT.PASTE, this.pasteFromClipboard),
addEventListener(document, EVENT.CUT, this.onCut),
addEventListener(window, EVENT.RESIZE, this.onResize, false),
addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
addEventListener(window, EVENT.BLUR, this.onBlur, false),
@@ -2638,7 +2619,6 @@ class App extends React.Component<AppProps, AppState> {
this.excalidrawContainerRef.current,
EVENT.WHEEL,
this.handleWheel,
{ passive: false },
),
addEventListener(
this.excalidrawContainerRef.current,
@@ -2660,7 +2640,6 @@ class App extends React.Component<AppProps, AppState> {
getNearestScrollableContainer(this.excalidrawContainerRef.current!),
EVENT.SCROLL,
this.onScroll,
{ passive: false },
),
);
}
@@ -3256,15 +3235,8 @@ class App extends React.Component<AppProps, AppState> {
}
});
// paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
if (isSafari) {
Fonts.loadElementsFonts(newElements).then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
}
if (opts.files) {
this.addMissingFiles(opts.files);
this.files = { ...this.files, ...opts.files };
}
this.store.shouldCaptureIncrement();
@@ -3773,56 +3745,23 @@ class App extends React.Component<AppProps, AppState> {
}
};
/**
* adds supplied files to existing files in the appState.
* NOTE if file already exists in editor state, the file data is not updated
* */
/** adds supplied files to existing files in the appState */
public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
(files) => {
const { addedFiles } = this.addMissingFiles(files);
const filesMap = files.reduce((acc, fileData) => {
acc.set(fileData.id, fileData);
return acc;
}, new Map<FileId, BinaryFileData>());
this.clearImageShapeCache(addedFiles);
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.clearImageShapeCache(Object.fromEntries(filesMap));
this.scene.triggerUpdate();
this.addNewImagesToImageCache();
},
);
private addMissingFiles = (
files: BinaryFiles | BinaryFileData[],
replace = false,
) => {
const nextFiles = replace ? {} : { ...this.files };
const addedFiles: BinaryFiles = {};
const _files = Array.isArray(files) ? files : Object.values(files);
for (const fileData of _files) {
if (nextFiles[fileData.id]) {
continue;
}
addedFiles[fileData.id] = fileData;
nextFiles[fileData.id] = fileData;
if (fileData.mimeType === MIME_TYPES.svg) {
const restoredDataURL = getDataURL_sync(
normalizeSVG(dataURLToString(fileData.dataURL)),
MIME_TYPES.svg,
);
if (fileData.dataURL !== restoredDataURL) {
// bump version so persistence layer can update the store
fileData.version = (fileData.version ?? 1) + 1;
fileData.dataURL = restoredDataURL;
}
}
}
this.files = nextFiles;
return { addedFiles };
};
public updateScene = withBatchedUpdates(
<K extends keyof AppState>(sceneData: {
elements?: SceneData["elements"];
@@ -4834,13 +4773,10 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
}
flushSync(() => {
this.setState({
newElement: null,
editingTextElement: null,
});
this.setState({
newElement: null,
editingTextElement: null,
});
if (this.state.activeTool.locked) {
setCursorForShape(this.interactiveCanvas, this.state);
}
@@ -7942,14 +7878,10 @@ class App extends React.Component<AppProps, AppState> {
isFrameLikeElement(e),
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
const frameToHighlight =
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
// Only update the state if there is a difference
if (this.state.frameToHighlight !== frameToHighlight) {
flushSync(() => {
this.setState({ frameToHighlight });
});
}
this.setState({
frameToHighlight:
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null,
});
// Marking that click was used for dragging to check
// if elements should be deselected on pointerup
@@ -8096,9 +8028,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
flushSync(() => {
this.setState({ snapLines });
});
this.setState({ snapLines });
// when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input
@@ -9351,7 +9281,7 @@ class App extends React.Component<AppProps, AppState> {
if (mimeType === MIME_TYPES.svg) {
try {
imageFile = SVGStringToFile(
normalizeSVG(await imageFile.text()),
await normalizeSVG(await imageFile.text()),
imageFile.name,
);
} catch (error: any) {
@@ -9419,15 +9349,16 @@ class App extends React.Component<AppProps, AppState> {
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
async (resolve, reject) => {
try {
this.addMissingFiles([
{
this.files = {
...this.files,
[fileId]: {
mimeType,
id: fileId,
dataURL,
created: Date.now(),
lastRetrieved: Date.now(),
},
]);
};
const cachedImageData = this.imageCache.get(fileId);
if (!cachedImageData) {
this.addNewImagesToImageCache();
@@ -9832,7 +9763,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas.addEventListener(
EVENT.TOUCH_START,
this.onTouchStart,
{ passive: false },
);
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
// -----------------------------------------------------------------------
@@ -10264,7 +10194,7 @@ class App extends React.Component<AppProps, AppState> {
croppingElement,
this.scene.getNonDeletedElementsMap(),
{
newSize: {
oldSize: {
width: croppingElement.width,
height: croppingElement.height,
},
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
});
if (!baseColorName) {
onChange(COLOR_PALETTE.charcoal);
onChange(COLOR_PALETTE.black);
}
}
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon,
text: t("labels.handDrawn"),
testId: "font-family-hand-drawn",
testId: "font-family-handrawn",
},
{
value: FONT_FAMILY.Nunito,
@@ -21,7 +21,6 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor {
value: number;
@@ -63,14 +62,12 @@ export const FontPickerList = React.memo(
const allFonts = useMemo(
() =>
Array.from(Fonts.registered.entries())
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
icon: metadata.icon,
text: fonts[0].fontFace.family,
};
if (metadata.deprecated) {
@@ -92,7 +89,7 @@ export const FontPickerList = React.memo(
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFamilies()),
() => new Set(fonts.getSceneFontFamilies()),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
@@ -22,7 +22,7 @@ const Header = () => (
</a>
<a
className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog"
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
@@ -23,6 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
@@ -35,7 +36,6 @@ import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { exportToCanvas } from "../scene/export";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -123,25 +123,19 @@ const ImageExportModal = ({
}
exportToCanvas({
data: {
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportEmbedScene: embedScene,
},
files,
},
config: {
canvasBackgroundColor: !exportWithBackground
? false
: appStateSnapshot.viewBackgroundColor,
padding: DEFAULT_EXPORT_PADDING,
theme: exportDarkMode ? "dark" : "light",
scale: exportScale,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
})
.then((canvas) => {
setRenderError(null);
@@ -1,9 +1,9 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types";
import { COLOR_WHITE } from "../constants";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types";
@@ -41,16 +41,14 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!;
(async () => {
svg = await exportToSvg({
data: {
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_WHITE,
},
files: null,
svg = await exportToSvg(
elements,
{
exportBackground: false,
viewBackgroundColor: oc.white,
},
});
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
@@ -7,8 +7,8 @@ import { t } from "../i18n";
import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export";
import {
COLOR_WHITE,
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
@@ -24,7 +24,6 @@ import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss";
import { exportToCanvas, exportToSvg } from "../scene/export";
interface PublishLibraryDataParams {
authorName: string;
@@ -56,20 +55,16 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = COLOR_WHITE;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
data: {
elements: item.elements,
files: null,
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
@@ -131,15 +126,14 @@ const SingleLibraryItem = ({
}
(async () => {
const svg = await exportToSvg({
data: {
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: COLOR_WHITE,
exportBackground: true,
},
files: null,
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
},
files: null,
skipInliningFonts: true,
});
node.innerHTML = svg.outerHTML;
})();
@@ -294,7 +294,6 @@ export const SearchMenu = () => {
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
passive: false,
});
}, [setAppState, stableState, app]);
@@ -69,6 +69,7 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@@ -78,7 +79,7 @@ const resizeElementInGroup = (
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
oldSize: { width: oldWidth, height: oldHeight },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -151,6 +151,8 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight);
}
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(
latestElement,
{
@@ -199,7 +201,7 @@ export const resizeElement = (
}
updateBoundElements(latestElement, elementsMap, {
newSize: { width: nextWidth, height: nextHeight },
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) {
@@ -91,16 +91,12 @@ export const convertMermaidToExcalidraw = async ({
};
const canvas = await exportToCanvas({
data: {
elements: data.current.elements,
files: data.current.files,
},
config: {
padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
},
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
@@ -97,6 +97,7 @@ const getRelevantAppStateProps = (
theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize,
+8 -32
View File
@@ -1,8 +1,7 @@
import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import type { NormalizedZoomValue } from "./types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
@@ -108,6 +107,7 @@ export const YOUTUBE_STATES = {
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
};
export const CLASSES = {
@@ -116,9 +116,6 @@ export const CLASSES = {
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
@@ -139,22 +136,6 @@ export const FONT_FAMILY = {
"Liberation Sans": 9,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
@@ -176,6 +157,8 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
@@ -183,14 +166,6 @@ export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
// -----------------------------------------------
// !!! these colors are tied to color picker !!!
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
export const COLOR_TRANSPARENT = "transparent";
// -----------------------------------------------
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
@@ -199,6 +174,8 @@ const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6";
@@ -309,7 +286,6 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_SMALLEST_EXPORT_SIZE = 20; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
@@ -390,8 +366,8 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: COLOR_CHARCOAL_BLACK,
backgroundColor: COLOR_TRANSPARENT,
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
@@ -2171,7 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"width": 150,
"x": 75,
"y": 275,
}
`;
@@ -2213,7 +2213,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ANOTHER STYLED
"text": "ANOTHER STYLED
LABELLED ARROW",
"textAlign": "center",
"type": "text",
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 140,
"x": 80,
"width": 150,
"x": 75,
"y": 375,
}
`;
@@ -2518,7 +2518,7 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "ELLIPSE TEXT
"text": "ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"width": 130,
"x": 534.7893218813452,
"y": 117.44796179957173,
}
`;
@@ -2562,7 +2562,7 @@ TEXT CONTAINER",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "DIAMOND
TEXT
TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2646,8 +2646,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
"text": "TOP LEFT ALIGNED
RECTANGLE TEXT
CONTAINER",
"textAlign": "left",
"type": "text",
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 160,
"width": 170,
"x": 505,
"y": 305,
}
@@ -2689,8 +2689,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "STYLED
ELLIPSE TEXT
"text": "STYLED
ELLIPSE TEXT
CONTAINER",
"textAlign": "center",
"type": "text",
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 539.7893218813452,
"width": 130,
"x": 534.7893218813452,
"y": 522.5735931288071,
}
`;
+4 -18
View File
@@ -8,14 +8,13 @@ import { calculateScrollCenter } from "../scene";
import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import type { FileSystemHandle } from "./filesystem";
import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => {
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if (blob.type === MIME_TYPES.png) {
@@ -47,7 +46,9 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
}
if (blob.type === MIME_TYPES.svg) {
try {
return (await import("./image")).decodeSvgMetadata({
return await (
await import("./image")
).decodeSvgMetadata({
svg: contents,
});
} catch (error: any) {
@@ -248,7 +249,6 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
}
};
/** async. For sync variant, use getDataURL_sync */
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -261,16 +261,6 @@ export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
});
};
export const getDataURL_sync = (
data: string | Uint8Array | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
): DataURL => {
return `data:${mimeType};base64,${stringToBase64(
toByteString(data),
true,
)}` as DataURL;
};
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1));
@@ -284,10 +274,6 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
return new File([ab], filename, { type: mimeType });
};
export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
export const resizeImageFile = async (
file: File,
opts: {
+27 -40
View File
@@ -5,23 +5,24 @@ import { encryptData, decryptData } from "./encryption";
// byte (binary) strings
// -----------------------------------------------------------------------------
// Buffer-compatible implem.
//
// Note that in V8, spreading the uint8array (by chunks) into
// `String.fromCharCode(...uint8array)` tends to be faster for large
// strings/buffers, in case perf is needed in the future.
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
const bytes =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: new Uint8Array(data);
let bstring = "";
for (const byte of bytes) {
bstring += String.fromCharCode(byte);
}
return bstring;
// 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) => {
@@ -45,12 +46,12 @@ const byteStringToString = (byteString: string) => {
* @param isByteString set to true if already byte string to prevent bloat
* due to reencoding
*/
export const stringToBase64 = (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
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 = (base64: string, isByteString = false) => {
export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString
? window.atob(base64)
: byteStringToString(window.atob(base64));
@@ -65,20 +66,6 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
return byteStringToArrayBuffer(atob(base64));
};
// -----------------------------------------------------------------------------
// base64url
// -----------------------------------------------------------------------------
export const base64urlToString = (str: string) => {
return window.atob(
// normalize base64URL to base64
str
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
);
};
// -----------------------------------------------------------------------------
// text encoding
// -----------------------------------------------------------------------------
@@ -95,18 +82,18 @@ type EncodedData = {
/**
* Encodes (and potentially compresses via zlib) text to byte string
*/
export const encode = ({
export const encode = async ({
text,
compress,
}: {
text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean;
}): EncodedData => {
}): Promise<EncodedData> => {
let deflated!: string;
if (compress !== false) {
try {
deflated = toByteString(deflate(text));
deflated = await toByteString(deflate(text));
} catch (error: any) {
console.error("encode: cannot deflate", error);
}
@@ -115,11 +102,11 @@ export const encode = ({
version: "1",
encoding: "bstring",
compressed: !!deflated,
encoded: deflated || toByteString(text),
encoded: deflated || (await toByteString(text)),
};
};
export const decode = (data: EncodedData): string => {
export const decode = async (data: EncodedData): Promise<string> => {
let decoded: string;
switch (data.encoding) {
@@ -127,7 +114,7 @@ export const decode = (data: EncodedData): string => {
// if compressed, do not double decode the bstring
decoded = data.compressed
? data.encoded
: byteStringToString(data.encoded);
: await byteStringToString(data.encoded);
break;
default:
throw new Error(`decode: unknown encoding "${data.encoding}"`);
+8 -8
View File
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw,
JSON.stringify(
encode({
await encode({
text: metadata,
compress: true,
}),
@@ -59,7 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
}
throw new Error("FAILED");
}
return decode(encodedData);
return await decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
@@ -72,9 +72,9 @@ export const decodePngMetadata = async (blob: Blob) => {
// SVG
// -----------------------------------------------------------------------------
export const encodeSvgMetadata = ({ text }: { text: string }) => {
const base64 = stringToBase64(
JSON.stringify(encode({ text })),
export const encodeSvgMetadata = async ({ text }: { text: string }) => {
const base64 = await stringToBase64(
JSON.stringify(await encode({ text })),
true /* is already byte string */,
);
@@ -87,7 +87,7 @@ export const encodeSvgMetadata = ({ text }: { text: string }) => {
return metadata;
};
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
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 -->/,
@@ -100,7 +100,7 @@ export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
const isByteString = version !== "1";
try {
const json = base64ToString(match[1], isByteString);
const json = await base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON
@@ -112,7 +112,7 @@ export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
}
throw new Error("FAILED");
}
return decode(encodedData);
return await decode(encodedData);
} catch (error: any) {
console.error(error);
throw new Error("FAILED");
+45 -63
View File
@@ -81,53 +81,46 @@ export const prepareElementsForExport = (
};
};
export const exportAsImage = async ({
type,
data,
config,
}: {
type: Omit<ExportType, "backend">;
data: {
elements: ExportedElements;
appState: AppState;
files: BinaryFiles;
};
config: {
export const exportCanvas = async (
type: Omit<ExportType, "backend">,
elements: ExportedElements,
appState: AppState,
files: BinaryFiles,
{
exportBackground,
exportPadding = DEFAULT_EXPORT_PADDING,
viewBackgroundColor,
name = appState.name || DEFAULT_FILENAME,
fileHandle = null,
exportingFrame = null,
}: {
exportBackground: boolean;
padding?: number;
exportPadding?: number;
viewBackgroundColor: string;
/** filename, if applicable */
name?: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null;
};
}) => {
// clone
const cfg = Object.assign({}, config);
cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING;
cfg.fileHandle = cfg.fileHandle ?? null;
cfg.exportingFrame = cfg.exportingFrame ?? null;
cfg.name = cfg.name || DEFAULT_FILENAME;
if (data.elements.length === 0) {
},
) => {
if (elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg({
data: {
elements: data.elements,
appState: {
exportBackground: cfg.exportBackground,
exportWithDarkMode: data.appState.exportWithDarkMode,
viewBackgroundColor: data.appState.viewBackgroundColor,
exportScale: data.appState.exportScale,
exportEmbedScene: data.appState.exportEmbedScene && type === "svg",
},
files: data.files,
const svgPromise = exportToSvg(
elements,
{
exportBackground,
exportWithDarkMode: appState.exportWithDarkMode,
viewBackgroundColor,
exportPadding,
exportScale: appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
config: { exportingFrame: cfg.exportingFrame, padding: cfg.padding },
});
files,
{ exportingFrame },
);
if (type === "svg") {
return fileSave(
svgPromise.then((svg) => {
@@ -135,9 +128,9 @@ export const exportAsImage = async ({
}),
{
description: "Export to SVG",
name: cfg.name,
extension: data.appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle: cfg.fileHandle,
name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle,
},
);
} else if (type === "clipboard-svg") {
@@ -151,33 +144,22 @@ export const exportAsImage = async ({
}
}
const tempCanvas = exportToCanvas({
data,
config: {
canvasBackgroundColor: !cfg.exportBackground
? false
: cfg.viewBackgroundColor,
padding: cfg.padding,
theme: data.appState.exportWithDarkMode ? "dark" : "light",
scale: data.appState.exportScale,
fit: "none",
exportingFrame: cfg.exportingFrame,
},
const tempCanvas = exportToCanvas(elements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportingFrame,
});
if (type === "png") {
const blob = canvasToBlob(tempCanvas);
if (data.appState.exportEmbedScene) {
blob.then((blob) =>
let blob = canvasToBlob(tempCanvas);
if (appState.exportEmbedScene) {
blob = blob.then((blob) =>
import("./image").then(({ encodePngMetadata }) =>
encodePngMetadata({
blob,
metadata: serializeAsJSON(
data.elements,
data.appState,
data.files,
"local",
),
metadata: serializeAsJSON(elements, appState, files, "local"),
}),
),
);
@@ -185,11 +167,11 @@ export const exportAsImage = async ({
return fileSave(blob, {
description: "Export to PNG",
name: cfg.name,
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: cfg.fileHandle,
fileHandle,
});
} else if (type === "clipboard") {
try {
+7 -12
View File
@@ -1,7 +1,6 @@
import type { ExcalidrawElement } from "../element/types";
import type { AppState, BinaryFiles } from "../types";
import { prepareElementsForExport } from ".";
import { exportAsImage } from ".";
import { exportCanvas, prepareElementsForExport } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async (
@@ -30,16 +29,12 @@ export const resaveAsImageWithScene = async (
false,
);
await exportAsImage({
type: fileHandleType,
data: { elements: exportedElements, appState, files },
config: {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
},
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
fileHandle,
exportingFrame,
});
return { fileHandle };
-4
View File
@@ -190,10 +190,6 @@ const restoreElementWithProperties = <
}
return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base,
...getNormalizedDimensions(base),
...extra,
+4 -4
View File
@@ -576,11 +576,11 @@ export const updateBoundElements = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
oldSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
},
) => {
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@@ -603,12 +603,12 @@ export const updateBoundElements = (
startBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.startBinding,
newSize,
oldSize,
),
endBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.endBinding,
newSize,
oldSize,
),
};
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping";
import { wrapText } from "./textElement";
import { isIframeElement } from "./typeChecks";
import type {
ExcalidrawElement,
+1 -1
View File
@@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = (SVGString: string) => {
export const normalizeSVG = async (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
+1 -1
View File
@@ -34,9 +34,9 @@ import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
normalizeText,
wrapText,
getBoundTextMaxWidth,
} from "./textElement";
import { wrapText } from "./textWrapping";
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
@@ -47,10 +47,10 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
wrapText,
measureText,
getMinTextElementWidth,
} from "./textElement";
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
@@ -739,9 +739,9 @@ export const resizeSingleElement = (
mutateElement(element, resizedElement);
updateBoundElements(element, elementsMap, {
newSize: {
width: resizedElement.width,
height: resizedElement.height,
oldSize: {
width: stateAtResizeStart.width,
height: stateAtResizeStart.height,
},
});
@@ -999,13 +999,14 @@ export const resizeMultipleElements = (
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { angle, width: newWidth, height: newHeight } = update;
const { angle } = update;
const { width: oldWidth, height: oldHeight } = element;
mutateElement(element, update, false);
updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width: newWidth, height: newHeight },
oldSize: { width: oldWidth, height: oldHeight },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
+227 -2
View File
@@ -1,4 +1,4 @@
import { FONT_FAMILY } from "../constants";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { getLineHeight } from "../fonts";
import { API } from "../tests/helpers/api";
import {
@@ -6,10 +6,235 @@ import {
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
wrapText,
detectLineHeight,
getLineHeightInPx,
parseTokens,
} from "./textElement";
import type { ExcalidrawTextElementWithContainer } from "./types";
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should work with emojis", () => {
const text = "😀";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀");
});
it("should show the text correctly when max width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello \nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 140,
res: `Hello whats \nup`,
},
{
desc: "fit the container",
width: 250,
res: "Hello whats up",
},
{
desc: "should push the word if its equal to max width",
width: 60,
res: `Hello
whats
up`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello
whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 80,
res: `Hello\nwhats \nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 25,
res: `H
e
l
l
o
w
h
a
t
s
u
p`,
},
{
desc: "break words as per the width",
width: 150,
res: `Hello
whats up`,
},
{
desc: "fit the container",
width: 250,
res: `Hello
whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 170,
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 130,
res: `hellolongte
xtthisiswha
tsupwithyou
Iamtypinggg
ggandtyping
gg break it
now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
expect(res).toEqual(data.res);
});
});
});
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello \nExcalidraw`);
});
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`,
);
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello \nthereusin\ng-now");
});
});
describe("Test parseTokens", () => {
it("should split into tokens correctly", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
expect(parseTokens(text)).toEqual([
"Excalidraw",
"is",
"a",
"virtual",
"collaborative",
"whiteboard",
]);
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
"is",
"hosted",
"by",
"Wikimedia-",
"",
"Foundation,",
"a",
"non-",
"profit",
"organization",
"that",
"also",
"hosts",
"a",
"range-",
"of",
"other",
"projects",
]);
});
});
describe("Test measureText", () => {
describe("Test getContainerCoords", () => {
+170 -12
View File
@@ -21,7 +21,6 @@ import {
} from "../constants";
import type { MaybeTransformHandleType } from "./transformHandles";
import { isTextElement } from ".";
import { wrapText } from "./textWrapping";
import { isBoundToContainer, isArrowElement } from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import type { AppState } from "../types";
@@ -344,7 +343,7 @@ let canvas: HTMLCanvasElement | undefined;
*
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/
export const getLineWidth = (
const getLineWidth = (
text: string,
font: FontString,
forceAdvanceWidth?: true,
@@ -409,34 +408,193 @@ export const getTextHeight = (
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const parseTokens = (text: string) => {
// Splitting words containing "-" as those are treated as separate words
// by css wrapping algorithm eg non-profit => non-, profit
const words = text.split("-");
if (words.length > 1) {
// non-proft org => ['non-', 'profit org']
words.forEach((word, index) => {
if (index !== words.length - 1) {
words[index] = word += "-";
}
});
}
// Joining the words with space and splitting them again with space to get the
// final list of tokens
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
return words.join(" ").split(" ");
};
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = "";
let currentLineWidthTillNow = 0;
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const resetParams = () => {
currentLine = "";
currentLineWidthTillNow = 0;
};
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
}
const words = parseTokens(originalLine);
resetParams();
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font, true);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
resetParams();
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!,
);
const line = currentLine + currentChar;
// use advance width instead of the actual width as it's closest to the browser wapping algo
// use width of the whole line instead of calculating individual chars to accomodate for kerning
const lineAdvanceWidth = getLineWidth(line, font, true);
const charAdvanceWidth = charWidth.calculate(currentChar, font);
currentLineWidthTillNow = lineAdvanceWidth;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = charAdvanceWidth;
} else {
currentLine = line;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line unless the line ends with hyphen to sync
// with css word-wrap
} else if (!currentLine.endsWith("-")) {
currentLine += " ";
currentLineWidthTillNow += spaceAdvanceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(
currentLine + word,
font,
true,
);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
resetParams();
break;
}
index++;
// if word ends with "-" then we don't need to add space
// to sync with css word-wrap
const shouldAppendSpace = !word.endsWith("-");
currentLine += word;
if (shouldAppendSpace) {
currentLine += " ";
}
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
lines.push(currentLine);
}
resetParams();
break;
}
}
}
}
if (currentLine.slice(-1) === " ") {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
}
return lines.join("\n");
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font, true);
cachedCharWidth[font][unicode] = width;
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][unicode];
return cachedCharWidth[font][ascii];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
@@ -1,633 +0,0 @@
import { wrapText, parseTokens } from "./textWrapping";
import type { FontString } from "./types";
describe("Test wrapText", () => {
// font is irrelevant as jsdom does not support FontFace API
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello\nExcalidraw`);
});
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
});
it("should show the text correctly when max width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
it("should not wrap number when wrapping line", () => {
const text = "don't wrap this number 99,100.99";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("don't wrap this number\n99,100.99");
});
it("should trim all trailing whitespaces", () => {
const text = "Hello ";
const maxWidth = 50;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello");
});
it("should trim all but one trailing whitespaces", () => {
const text = "Hello ";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello ");
});
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
const text = " Hello World";
const maxWidth = 90;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" Hello\nWorld");
});
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
const text = " Hello World ";
const maxWidth = 90;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" Hello\nWorld ");
});
it("should trim keep those whitespace that fit in the trailing line", () => {
const text = "Hello Wo rl d ";
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello Wo\nrl d ");
});
it("should support multiple (multi-codepoint) emojis", () => {
const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿");
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
);
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
});
it("should support wrapping nested lists", () => {
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
);
});
it("should break CJK text into longer segments when width is larger", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
// measureText is mocked, so it's not precisely what would happen in prod
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
});
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
const maxWidth = 150;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
const maxWidth3 = 30;
const res3 = wrapText(text, font, maxWidth3);
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
});
it("should break before and after a regular CJK character", () => {
const text = "HelloたWorld";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("Hello\nた\nWorld");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("Helloた\nWorld");
});
it("should break before and after certain CJK symbols", () => {
const text = "こんにちは〃世界";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("こんにちは\n〃世界");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("こんにちは〃\n世界");
});
it("should break after, not before for certain CJK pairs", () => {
const text = "Hello た。";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\nた。");
});
it("should break before, not after for certain CJK pairs", () => {
const text = "Hello「たWorld」";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\n「た\nWorld」");
});
it("should break after, not before for certain CJK character pairs", () => {
const text = "「Helloた」World";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("「Hello\nた」World");
});
it("should break Chinese sentences", () => {
const text = `中国你好!这是一个测试。
¥1234
 `;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`中国你好!这是一\n个测试。
\n币¥1234\n贵
\n句号 \n全角符号`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`中国你好!\n这是一个测\n试。
\n看\n¥1234\n
\n逗号\n号\n换行 \n符号`);
});
it("should break Japanese sentences", () => {
const text = `日本こんにちは!これはテストです。
1234
 `;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
\nう1234\n
\n点
\n全角記号`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
\nましょう\n円\n1234\n
\n弧\n点
\n改行 \n記号`);
});
it("should break Korean sentences", () => {
const text = `한국 안녕하세요! 이것은 테스트입니다.
보자: 원화1234
(), , .
 `;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
보자: \n화1234\n싸다
(), \n표, .
 \n각기호`);
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
:\n원화\n1234\n
(),\n쉼표, \n표.
\n전각기호`);
});
});
describe("When text contains leading whitespaces", () => {
const text = " \t Hello world";
it("should preserve leading whitespaces", () => {
const maxWidth = 120;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" \t Hello\nworld");
});
it("should break and collapse leading whitespaces when line breaks", () => {
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHello\nworld");
});
it("should break and collapse leading whitespaces whe words break", () => {
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHel\nlo\nwor\nld");
});
});
describe("When text contains trailing whitespaces", () => {
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 190;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should ignore trailing whitespaces when line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 400;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
});
it("should not ignore trailing whitespaces when word breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
});
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 180;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
});
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\nwhats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 130,
res: `Hello whats\nup`,
},
{
desc: "fit the container",
width: 240,
res: "Hello whats up",
},
{
desc: "push the word if its equal to max width",
width: 50,
res: `Hello\nwhats\nup`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello\n whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\n whats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 140,
res: `Hello\n whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 160,
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 120,
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 590,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("Test parseTokens", () => {
it("should tokenize latin", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
expect(parseTokens(text)).toEqual([
"Excalidraw",
" ",
"is",
" ",
"a",
" ",
"virtual",
" ",
"collaborative",
" ",
"whiteboard",
]);
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
" ",
"is",
" ",
"hosted",
" ",
"by",
" ",
"Wikimedia-",
" ",
"Foundation,",
" ",
"a",
" ",
"non-",
"profit",
" ",
"organization",
" ",
"that",
" ",
"also",
" ",
"hosts",
" ",
"a",
" ",
"range-",
"of",
" ",
"other",
" ",
"projects",
]);
});
it("should not tokenize number", () => {
const text = "99,100.99";
const tokens = parseTokens(text);
expect(tokens).toEqual(["99,100.99"]);
});
it("should tokenize joined emojis", () => {
const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"🌍",
"🗺",
"🔥",
"☂️",
"👩🏽‍🦰",
"👨‍👩‍👧‍👦",
"👩🏾‍🔬",
"🏳️‍🌈",
"🧔‍♀️",
"🧑‍🤝‍🧑",
"🙅🏽‍♂️",
"✅",
"0️⃣",
"🇨🇿",
"🦅",
]);
});
it("should tokenize emojis mixed with mixed text", () => {
const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"a",
"🌍",
"b",
"🗺",
"c",
"🔥",
"d",
"☂️",
"《",
"👩🏽‍🦰",
"》",
"👨‍👩‍👧‍👦",
"德",
"👩🏾‍🔬",
"こ",
"🏳️‍🌈",
"안",
"🧔‍♀️",
"g",
"🧑‍🤝‍🧑",
"h",
"🙅🏽‍♂️",
"e",
"✅",
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
"🇨🇿",
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
"🦅",
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
]);
});
it("should tokenize decomposed chars into their composed variants", () => {
// each input character is in a decomposed form
const text = "čでäぴέ다й한";
expect(text.normalize("NFC").length).toEqual(8);
expect(text).toEqual(text.normalize("NFD"));
const tokens = parseTokens(text);
expect(tokens.length).toEqual(8);
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
});
it("should tokenize artificial CJK", () => {
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
// [
// '《道', '德', '經》', '醫-',
// '醫', 'こ', 'ん', 'に',
// 'ち', 'は', '世', '界!',
// '안', '녕', '하', '세',
// '요', '세', '계;', '요』,',
// '다.', '다...', '원/', '달',
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
// 'た…', '[Hello]', ' ', '\t',
// ' ', 'World', 'ニ', 'ュ',
// 'ー', 'ヨ', 'ー', 'ク・',
// '¥3700.55', 'す。', '090-', '1234-',
// '5678', '¥1,000〜', '5,000', '「素',
// '晴', 'ら', 'し', 'い!」',
// '〔重', '要〕', '', '1:',
// 'Taro', '君', '30', 'は、',
// '(た', 'な', 'ば', 'た)',
// '〰', '¥110±', '¥570', 'で',
// '20℃〜', '9:30〜', '10:00', '【一',
// '番】'
// ]
const tokens = parseTokens(text);
// Latin
expect(tokens).toContain("[[1]]");
expect(tokens).toContain("[Hello]");
expect(tokens).toContain("World");
expect(tokens).toContain("Taro");
// Chinese
expect(tokens).toContain("《道");
expect(tokens).toContain("德");
expect(tokens).toContain("經》");
expect(tokens).toContain("醫-");
expect(tokens).toContain("醫");
// Japanese
expect(tokens).toContain("こ");
expect(tokens).toContain("ん");
expect(tokens).toContain("に");
expect(tokens).toContain("ち");
expect(tokens).toContain("は");
expect(tokens).toContain("世");
expect(tokens).toContain("ク・");
expect(tokens).toContain("界!");
expect(tokens).toContain("た…");
expect(tokens).toContain("す。");
expect(tokens).toContain("ュ");
expect(tokens).toContain("「素");
expect(tokens).toContain("晴");
expect(tokens).toContain("ら");
expect(tokens).toContain("し");
expect(tokens).toContain("い!」");
expect(tokens).toContain("君");
expect(tokens).toContain("は、");
expect(tokens).toContain("(た");
expect(tokens).toContain("な");
expect(tokens).toContain("ば");
expect(tokens).toContain("た)");
expect(tokens).toContain("で");
expect(tokens).toContain("【一");
expect(tokens).toContain("番】");
// Check for Korean
expect(tokens).toContain("안");
expect(tokens).toContain("녕");
expect(tokens).toContain("하");
expect(tokens).toContain("세");
expect(tokens).toContain("요");
expect(tokens).toContain("세");
expect(tokens).toContain("계;");
expect(tokens).toContain("요』,");
expect(tokens).toContain("다.");
expect(tokens).toContain("다...");
expect(tokens).toContain("원/");
expect(tokens).toContain("달");
expect(tokens).toContain("(((다)))");
expect(tokens).toContain("〚({((한))>)〛");
expect(tokens).toContain("(「た」)");
// Numbers and units
expect(tokens).toContain("¥3700.55");
expect(tokens).toContain("090-");
expect(tokens).toContain("1234-");
expect(tokens).toContain("5678");
expect(tokens).toContain("¥1,000〜");
expect(tokens).toContain("5,000");
expect(tokens).toContain("1:");
expect(tokens).toContain("30");
expect(tokens).toContain("¥110±");
expect(tokens).toContain("20℃〜");
expect(tokens).toContain("9:30〜");
expect(tokens).toContain("10:00");
// Punctuation and symbols
expect(tokens).toContain(" ");
expect(tokens).toContain("\t");
expect(tokens).toContain(" ");
expect(tokens).toContain("ニ");
expect(tokens).toContain("ー");
expect(tokens).toContain("ヨ");
expect(tokens).toContain("〰");
expect(tokens).toContain("");
});
});
});
-568
View File
@@ -1,568 +0,0 @@
import { ENV } from "../constants";
import { charWidth, getLineWidth } from "./textElement";
import type { FontString } from "./types";
let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
/**
* Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
*/
export const containsCJK = (text: string) => {
if (!cachedCjkRegex) {
cachedCjkRegex = Regex.class(...Object.values(CJK));
}
return cachedCjkRegex.test(text);
};
const getLineBreakRegex = () => {
if (!cachedLineBreakRegex) {
try {
cachedLineBreakRegex = getLineBreakRegexAdvanced();
} catch {
cachedLineBreakRegex = getLineBreakRegexSimple();
}
}
return cachedLineBreakRegex;
};
const getEmojiRegex = () => {
if (!cachedEmojiRegex) {
cachedEmojiRegex = getEmojiRegexUnicode();
}
return cachedEmojiRegex;
};
/**
* Common symbols used across different languages.
*/
const COMMON = {
/**
* Natural breaking points for any grammars.
*
* Hello world
* BREAK ALWAYS " " ["Hello", " ", "world"]
* Hello-world
* BREAK AFTER "-" ["Hello-", "world"]
*/
WHITESPACE: /\s/u,
HYPHEN: /-/u,
/**
* Generally do not break, unless closed symbol is followed by an opening symbol.
*
* Also, western punctation is often used in modern Korean and expects to be treated
* similarly to the CJK opening and closing symbols.
*
* Hello() ["Hello", "(한", "글)"]
* BREAK BEFORE "("
* BREAK AFTER ")"
*/
OPENING: /<\(\[\{/u,
CLOSING: />\)\]\}.,:;!\?\//u,
};
/**
* Characters and symbols used in Chinese, Japanese and Korean.
*/
const CJK = {
/**
* Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
*
* Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
*/
CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u,
/**
* Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
* and creates pairs with neighboring characters.
*
* Hello ["Hello", "た。"]
* DON'T BREAK "た。"
* * Hello World ["Hello", "「た」", "World"]
* DON'T BREAK "「た"
* DON'T BREAK "た"
* BREAK BEFORE "「"
* BREAK AFTER "」"
*/
// eslint-disable-next-line prettier/prettier
OPENING://u,
CLOSING: //u,
/**
* Currency symbols break before, not after
*
* Price100 ["Price", "¥100"]
* BREAK BEFORE "¥"
*/
CURRENCY: //u,
};
const EMOJI = {
FLAG: /\p{RI}\p{RI}/u,
JOINER:
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
ZWJ: /\u200D/u,
ANY: /[\p{Emoji}]/u,
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
};
/**
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
*
* Browser support as of 10/2024:
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
*
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
*/
const getLineBreakRegexSimple = () =>
Regex.or(
getEmojiRegex(),
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
);
/**
* Specifies the line breaking rules based for alphabetic-based languages,
* Chinese, Japanese, Korean and Emojis.
*
* "Hello-world" ["Hello-", "world"]
* "Hello 「世界。」🌎🗺" ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
*/
const getLineBreakRegexAdvanced = () =>
Regex.or(
// Unicode-defined regex for (multi-codepoint) Emojis
getEmojiRegex(),
// Rules for whitespace and hyphen
Break.Before(COMMON.WHITESPACE).Build(),
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
// Rules for CJK (chars, symbols, currency)
Break.Before(CJK.CHAR, CJK.CURRENCY)
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
.Build(),
Break.After(CJK.CHAR)
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
.Build(),
// Rules for opening and closing punctuation
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
);
/**
* Matches various emoji types.
*
* 1. basic emojis (😀, 🌍)
* 2. flags (🇨🇿)
* 3. multi-codepoint emojis:
* - skin tones (👍🏽)
* - variation selectors ()
* - keycaps (1)
* - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
* - emoji sequences (👨👩👧👦, 👩🚀, 🏳🌈)
*
* Unicode points:
* - \uFE0F: presentation selector
* - \u20E3: enclosing keycap
* - \u200D: zero width joiner
* - \u{E0020}-\u{E007E}: tags
* - \u{E007F}: cancel tag
*
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
*/
const getEmojiRegexUnicode = () =>
Regex.group(
Regex.or(
EMOJI.FLAG,
Regex.and(
EMOJI.MOST,
EMOJI.JOINER,
Regex.build(
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
),
),
),
);
/**
* Regex utilities for unicode character classes.
*/
const Regex = {
/**
* Builds a regex from a string.
*/
build: (regex: string): RegExp => new RegExp(regex, "u"),
/**
* Joins regexes into a single string.
*/
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
/**
* Joins regexes into a single regex as with "and" operator.
*/
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
/**
* Joins regexes into a single regex with "or" operator.
*/
or: (...regexes: RegExp[]): RegExp =>
Regex.build(regexes.map((x) => x.source).join("|")),
/**
* Puts regexes into a matching group.
*/
group: (...regexes: RegExp[]): RegExp =>
Regex.build(`(${Regex.join(...regexes)})`),
/**
* Puts regexes into a character class.
*/
class: (...regexes: RegExp[]): RegExp =>
Regex.build(`[${Regex.join(...regexes)}]`),
};
/**
* Human-readable lookahead and lookbehind utilities for defining line break
* opportunities between pairs of character classes.
*/
const Break = {
/**
* Break on the given class of characters.
*/
On: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
return Regex.build(`([${joined}])`);
},
/**
* Break before the given class of characters.
*/
Before: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after the given class of characters.
*/
After: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Break before one or multiple characters of the same class.
*/
BeforeMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after one or multiple character from the same class.
*/
AfterMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Do not break before the given class of characters.
*/
NotBefore: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
/**
* Do not break after the given class of characters.
*/
NotAfter: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
Chain: (rootBuilder: () => RegExp) => ({
/**
* Build the root regex.
*/
Build: rootBuilder,
/**
* Specify additional class of characters that should precede the root regex.
*/
PreceededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const preceeded = Break.After(...regexes).Build();
const builder = () => Regex.and(preceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Specify additional class of characters that should follow the root regex.
*/
FollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const followed = Break.Before(...regexes).Build();
const builder = () => Regex.and(root, followed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Specify additional class of characters that should not precede the root regex.
*/
NotPrecededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notPreceeded = Break.NotAfter(...regexes).Build();
const builder = () => Regex.and(notPreceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
/**
* Specify additional class of characters that should not follow the root regex.
*/
NotFollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notFollowed = Break.NotBefore(...regexes).Build();
const builder = () => Regex.and(root, notFollowed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
}),
};
/**
* Breaks the line into the tokens based on the found line break opporutnities.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
// normalizing to single-codepoint composed chars due to canonical equivalence
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
// filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
};
/**
* Wraps the original text into the lines based on the given width.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
}
return lines.join("\n");
};
/**
* Wraps the original line into the lines based on the given width.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineWidth = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
currentLine = testLine;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true);
iterator = tokenIterator.next();
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth);
lines.push(trailingLine);
}
return lines;
};
/**
* Wraps the word into the lines based on the given width.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [word];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineWidth = 0;
for (const char of chars) {
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
currentLine = currentLine + char;
currentLineWidth = testLineWidth;
continue;
}
if (currentLine) {
lines.push(currentLine);
}
currentLine = char;
currentLineWidth = _charWidth;
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
/**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*/
const trimLine = (line: string, font: FontString, maxWidth: number) => {
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
}
// defensively default to `trimeEnd` in case the regex does not match
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
line,
line.trimEnd(),
"",
];
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font);
const testLineWidth = trimmedLineWidth + _charWidth;
if (testLineWidth > maxWidth) {
break;
}
trimmedLine = trimmedLine + whitespace;
trimmedLineWidth = testLineWidth;
}
return trimmedLine;
};
/**
* Check if the given string is a single character.
*
* Handles multi-byte chars (é, ) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰).
*/
const isSingleCharacter = (maybeSingleCharacter: string) => {
return (
maybeSingleCharacter.codePointAt(0) !== undefined &&
maybeSingleCharacter.codePointAt(1) === undefined
);
};
/**
* Invariant for the word wrapping algorithm.
*/
const satisfiesWordInvariant = (word: string) => {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
if (/\s/.test(word)) {
throw new Error("Word should not contain any whitespaces!");
}
}
};
@@ -917,7 +917,7 @@ describe("textWysiwyg", () => {
Keyboard.exitTextEditor(editor);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.text).toBe("Hello\nWorld!");
expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2,
@@ -1220,7 +1220,7 @@ describe("textWysiwyg", () => {
);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
"Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy",
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
+9 -4
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, POINTER_BUTTON } from "../constants";
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -27,13 +27,13 @@ import {
getTextWidth,
normalizeText,
redrawTextBoundingBox,
wrapText,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
computeContainerDimensionForBoundText,
computeBoundTextPosition,
getBoundTextElement,
} from "./textElement";
import { wrapText } from "./textWrapping";
import {
actionDecreaseFontSize,
actionIncreaseFontSize,
@@ -245,6 +245,11 @@ export const textWysiwyg = ({
const font = getFontString(updatedTextElement);
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
const padding = !isSafari
? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
: 0;
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
@@ -254,7 +259,7 @@ export const textWysiwyg = ({
lineHeight: updatedTextElement.lineHeight,
width: `${width}px`,
height: `${height}px`,
left: `${viewportX}px`,
left: `${viewportX - padding}px`,
top: `${viewportY}px`,
transform: getTransform(
width,
@@ -264,6 +269,7 @@ export const textWysiwyg = ({
maxWidth,
editorMaxHeight,
),
padding: `0 ${padding}px`,
textAlign,
verticalAlign,
color: updatedTextElement.strokeColor,
@@ -304,7 +310,6 @@ export const textWysiwyg = ({
minHeight: "1em",
backfaceVisibility: "hidden",
margin: 0,
padding: 0,
border: 0,
outline: 0,
resize: "none",
-37
View File
@@ -36,40 +36,3 @@ export class ImageSceneDataError extends Error {
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
}
type WorkerErrorCodes = "WORKER_URL_NOT_DEFINED" | "WORKER_IN_THE_MAIN_CHUNK";
export class WorkerUrlNotDefinedError extends Error {
public code;
constructor(
message = "Worker URL is not defined!",
code: WorkerErrorCodes = "WORKER_URL_NOT_DEFINED",
) {
super(message);
this.name = "WorkerUrlNotDefinedError";
this.code = code;
}
}
export class WorkerInTheMainChunkError extends Error {
public code;
constructor(
message = "Worker has to be in a separate chunk!",
code: WorkerErrorCodes = "WORKER_IN_THE_MAIN_CHUNK",
) {
super(message);
this.name = "WorkerInTheMainChunkError";
this.code = code;
}
}
/**
* Use this for generic, handled errors, so you can check against them
* and rethrow if needed
*/
export class ExcalidrawError extends Error {
constructor(message: string) {
super(message);
this.name = "ExcalidrawError";
}
}
@@ -1,9 +0,0 @@
import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const CascadiaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: CascadiaCodeRegular,
},
];
File diff suppressed because it is too large Load Diff
@@ -1,96 +0,0 @@
// The following file content was generated with https://chinese-font.netlify.app/online-split,
// but has been manully rewritten from `@font-face` rules into TS while leveraging FontFace API.
import _0 from "./ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2";
import _1 from "./ComicShanns-Regular-fcb0fc02dcbee4c9846b3e2508668039.woff2";
import _2 from "./ComicShanns-Regular-dc6a8806fa96795d7b3be5026f989a17.woff2";
import _3 from "./ComicShanns-Regular-6e066e8de2ac57ea9283adb9c24d7f0c.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
/* Generated By cn-font-split@5.2.2 https://www.npmjs.com/package/cn-font-split
CreateTime: Thu, 17 Oct 2024 09:57:51 GMT;
Origin File Name Table:
copyright: MIT License
Copyright (c) 2018 Shannon Miwa
Copyright (c) 2023 Jesus Gonzalez
Copyright (c) 2023 Rodrigo Batista de Moraes
Copyright (c) 2024 Fini Jastrow
Copyright (c) 2024 Kyle Beechly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
fontFamily: Comic Shanns Mono-Regular
fontSubfamily: Regular
uniqueID: FontForge 2.0 : Comic Shanns Mono Regular : 17-10-2024
fullName: Comic Shanns Mono Regular
version: 1.3.0
postScriptName: ComicShannsMono-Regular
license: MIT License
Copyright (c) 2018 Shannon Miwa
Copyright (c) 2023 Jesus Gonzalez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
preferredFamily: Comic Shanns Mono
*/
export const ComicShannsFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: _0,
descriptors: {
unicodeRange:
"U+20-7e,U+a1-a6,U+a8,U+ab-ac,U+af-b1,U+b4,U+b8,U+bb-bc,U+bf-cf,U+d1-d7,U+d9-de,U+e0-ef,U+f1-f7,U+f9-ff,U+131,U+152-153,U+2c6,U+2da,U+2dc,U+2013-2014,U+2018-201a,U+201c-201d,U+2020-2022,U+2026,U+2039-203a,U+2044,U+20ac,U+2191,U+2193,U+2212",
},
},
{
uri: _1,
descriptors: {
unicodeRange:
"U+100-10f,U+112-125,U+128-130,U+134-137,U+139-13c,U+141-148,U+14c-151,U+154-161,U+164-165,U+168-17f,U+1bf,U+1f7,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+a75b",
},
},
{
uri: _2,
descriptors: {
unicodeRange:
"U+2c7,U+2d8-2d9,U+2db,U+2dd,U+315,U+2190,U+2192,U+2200,U+2203-2204,U+2264-2265,U+f6c3",
},
},
{
uri: _3,
descriptors: { unicodeRange: "U+3bb" },
},
];
-9
View File
@@ -1,9 +0,0 @@
import { LOCAL_FONT_PROTOCOL } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const EmojiFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LOCAL_FONT_PROTOCOL,
},
];
+214
View File
@@ -0,0 +1,214 @@
import {
base64ToArrayBuffer,
stringToBase64,
toByteString,
} from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader";
export interface Font {
urls: URL[];
fontFace: FontFace;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
}
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
export class ExcalidrawFont implements Font {
public readonly urls: URL[];
public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFont.createUrls(uri);
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
.join(", ");
this.fontFace = new FontFace(family, sources, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
// it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
const response = await fetch(url, {
headers: {
Accept: "font/woff2",
},
});
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
// response not ok, try to continue
errorMessages.push(
`"${url.toString()}" returned status "${response.status}"`,
);
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
}
console.error(
`Failed to fetch font "${
this.fontFace.family
}" from urls "${this.urls.toString()}`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
/**
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
try {
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
const { compress, decompress } = await loadWoff2();
const { subset } = await loadHbSubset();
const decompressedBinary = decompress(arrayBuffer).buffer;
const subsetSnft = subset(decompressedBinary, codePoints);
const compressedBinary = compress(subsetSnft.buffer);
return ExcalidrawFont.toBase64(compressedBinary.buffer);
} catch (e) {
console.error("Skipped glyph subsetting", e);
// Fallback to encoding whole font in case of errors
return ExcalidrawFont.toBase64(arrayBuffer);
}
}
private static async toBase64(arrayBuffer: ArrayBuffer) {
let base64: string;
if (typeof Buffer !== "undefined") {
// node + server-side
base64 = Buffer.from(arrayBuffer).toString("base64");
} else {
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
}
return `data:font/woff2;base64,${base64}`;
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http") || uri.startsWith("data")) {
// one url for http imports or data url
return [new URL(uri)];
}
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL) {
try {
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
}
}
@@ -1,209 +0,0 @@
import { promiseTry } from "../utils";
import { LOCAL_FONT_PROTOCOL } from "./FontMetadata";
import { subsetWoff2GlyphsByCodepoints } from "../subset/subset-main";
type DataURL = string;
export class ExcalidrawFontFace {
public readonly urls: URL[] | DataURL[];
public readonly fontFace: FontFace;
private static readonly UNPKG_FALLBACK_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFontFace.createUrls(uri);
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFontFace.getFormat(url)}`)
.join(", ");
this.fontFace = new FontFace(family, sources, {
display: "swap",
style: "normal",
weight: "400",
...descriptors,
});
}
/**
* Generates CSS `@font-face` definition with the (subsetted) font source as a data url for the characters within the unicode range.
*
* Retrieves `undefined` otherwise.
*/
public toCSS(characters: string): Promise<string> | undefined {
// quick exit in case the characters are not within this font face's unicode range
if (!this.getUnicodeRangeRegex().test(characters)) {
return;
}
const codepoints = Array.from(characters).map(
(char) => char.codePointAt(0)!,
);
return this.getContent(codepoints).then(
(content) =>
`@font-face { font-family: ${this.fontFace.family}; src: url(${content}); }`,
);
}
/**
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
*/
public async getContent(codePoints: Array<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
try {
const arrayBuffer = await this.fetchFont(url);
const base64 = await subsetWoff2GlyphsByCodepoints(
arrayBuffer,
codePoints,
);
return base64;
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
}
console.error(
`Failed to fetch font family "${this.fontFace.family}"`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
public fetchFont(url: URL | DataURL): Promise<ArrayBuffer> {
return promiseTry(async () => {
const response = await fetch(url, {
// always prefer cache (even stale), otherwise it always triggers an unnecessary validation request
// which we don't need as we are controlling freshness of the fonts with the stable hash suffix in the url
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache
cache: "force-cache",
headers: {
Accept: "font/woff2",
},
});
if (!response.ok) {
const urlString = url instanceof URL ? url.toString() : "dataurl";
throw new Error(
`Failed to fetch "${urlString}": ${response.statusText}`,
);
}
const arrayBuffer = await response.arrayBuffer();
return arrayBuffer;
});
}
private getUnicodeRangeRegex() {
// using \u{h} or \u{hhhhh} to match any number of hex digits,
// otherwise we would get an "Invalid Unicode escape" error
// e.g. U+0-1007F -> \u{0}-\u{1007F}
const unicodeRangeRegex = this.fontFace.unicodeRange
.split(/,\s*/)
.map((range) => {
const [start, end] = range.replace("U+", "").split("-");
if (end) {
return `\\u{${start}}-\\u{${end}}`;
}
return `\\u{${start}}`;
})
.join("");
return new RegExp(`[${unicodeRangeRegex}]`, "u");
}
private static createUrls(uri: string): URL[] | DataURL[] {
if (uri.startsWith("data")) {
// don't create the URL instance, as parsing the huge dataurl string is expensive
return [uri];
}
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http")) {
// one url for http imports or data url
return [new URL(uri)];
}
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, ExcalidrawFontFace.UNPKG_FALLBACK_URL));
return urls;
}
private static getFormat(url: URL | DataURL) {
if (!(url instanceof URL)) {
// format is irrelevant for data url
return "";
}
try {
const parts = new URL(url).pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
}
}
@@ -1,160 +0,0 @@
import _0 from "./Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2";
import _1 from "./Excalifont-Regular-be310b9bcd4f1a43f571c46df7809174.woff2";
import _2 from "./Excalifont-Regular-b9dcf9d2e50a1eaf42fc664b50a3fd0d.woff2";
import _3 from "./Excalifont-Regular-41b173a47b57366892116a575a43e2b6.woff2";
import _4 from "./Excalifont-Regular-3f2c5db56cc93c5a6873b1361d730c16.woff2";
import _5 from "./Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2";
import _6 from "./Excalifont-Regular-623ccf21b21ef6b3a0d87738f77eb071.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
/* Generated By cn-font-split@5.2.2 https://www.npmjs.com/package/cn-font-split
CreateTime: Mon, 14 Oct 2024 18:59:19 GMT;
Origin File Name Table:
copyright: Copyright (c) 2024 by Excalidraw. All rights reserved.
fontFamily: Excalifont
fontSubfamily: Regular
uniqueID: 1.000;DSGN;Excalifont
fullName: Excalifont Regular
version: Version 1.000;Glyphs 3.2 (3227)
postScriptName: Excalifont-Regular
trademark: Excalifont is a trademark of Excalidraw.
manufacturer: Your Own Font Foundry (Virgil); Ján Filípek / DizajnDesign (Excalifont, modifications)
designer: Your Own Font Foundry (Virgil); Ján Filípek / DizajnDesign (Excalifont, modifications)
manufacturerURL: https://dizajndesign.sk
designerURL: https://dizajndesign.sk
license: This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
licenseURL: http://scripts.sil.org/OFL
preferredFamily: Excalifont
preferredSubfamily: Regular
*/
export const ExcalifontFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: _0,
descriptors: {
unicodeRange:
"U+20-7e,U+a0-a3,U+a5-a6,U+a8-ab,U+ad-b1,U+b4,U+b6-b8,U+ba-ff,U+131,U+152-153,U+2bc,U+2c6,U+2da,U+2dc,U+304,U+308,U+2013-2014,U+2018-201a,U+201c-201e,U+2020,U+2022,U+2024-2026,U+2030,U+2039-203a,U+20ac,U+2122,U+2212",
},
},
{
uri: _1,
descriptors: {
unicodeRange:
"U+100-130,U+132-137,U+139-149,U+14c-151,U+154-17e,U+192,U+1fc-1ff,U+218-21b,U+237,U+1e80-1e85,U+1ef2-1ef3,U+2113",
},
},
{ uri: _2, descriptors: { unicodeRange: "U+400-45f,U+490-491,U+2116" } },
{
uri: _3,
descriptors: {
unicodeRange:
"U+37e,U+384-38a,U+38c,U+38e-393,U+395-3a1,U+3a3-3a8,U+3aa-3cf,U+3d7",
},
},
{
uri: _4,
descriptors: {
unicodeRange:
"U+2c7,U+2d8-2d9,U+2db,U+2dd,U+302,U+306-307,U+30a-30c,U+326-328,U+212e,U+2211,U+fb01-fb02",
},
},
{
uri: _5,
descriptors: {
unicodeRange:
"U+462-463,U+472-475,U+4d8-4d9,U+4e2-4e3,U+4e6-4e9,U+4ee-4ef",
},
},
{ uri: _6, descriptors: { unicodeRange: "U+300-301,U+303" } },
];
-516
View File
@@ -1,516 +0,0 @@
import {
FONT_FAMILY,
FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks,
} from "../constants";
import { isTextElement } from "../element";
import { charWidth, getContainerElement } from "../element/textElement";
import { containsCJK } from "../element/textWrapping";
import { ShapeCache } from "../scene/ShapeCache";
import { getFontString, PromisePool, promiseTry } from "../utils";
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
import { CascadiaFontFaces } from "./Cascadia";
import { ComicShannsFontFaces } from "./ComicShanns";
import { EmojiFontFaces } from "./Emoji";
import { ExcalifontFontFaces } from "./Excalifont";
import { HelveticaFontFaces } from "./Helvetica";
import { LiberationFontFaces } from "./Liberation";
import { LilitaFontFaces } from "./Lilita";
import { NunitoFontFaces } from "./Nunito";
import { VirgilFontFaces } from "./Virgil";
import { XiaolaiFontFaces } from "./Xiaolai";
import { FONT_METADATA, type FontMetadata } from "./FontMetadata";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FontFamilyValues,
} from "../element/types";
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint
public static readonly loadedFontsCache = new Set<string>();
private static _registered:
| Map<
number,
{
metadata: FontMetadata;
fontFaces: ExcalidrawFontFace[];
}
>
| undefined;
private static _initialized: boolean = false;
public static get registered() {
// lazy load the font registration
if (!Fonts._registered) {
Fonts._registered = Fonts.init();
} else if (!Fonts._initialized) {
// case when host app register fonts before they are lazy loaded
// don't override whatever has been previously registered
Fonts._registered = new Map([
...Fonts.init().entries(),
...Fonts._registered.entries(),
]);
}
return Fonts._registered;
}
public get registered() {
return Fonts.registered;
}
private readonly scene: Scene;
constructor(scene: Scene) {
this.scene = scene;
}
/**
* Get all the font families for the given scene.
*/
public getSceneFamilies = () => {
return Fonts.getUniqueFamilies(this.scene.getNonDeletedElements());
};
/**
* if we load a (new) font, it's likely that text elements using it have
* already been rendered using a fallback font. Thus, we want invalidate
* their shapes and rerender. See #637.
*
* Invalidates text elements and rerenders scene, provided that at least one
* of the supplied fontFaces has not already been processed.
*/
public onLoaded = (fontFaces: readonly FontFace[]): void => {
// bail if all fonts with have been processed. We're checking just a
// subset of the font properties (though it should be enough), so it
// can technically bail on a false positive.
let shouldBail = true;
for (const fontFace of fontFaces) {
const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
// make sure to update our cache with all the loaded font faces
if (!Fonts.loadedFontsCache.has(sig)) {
Fonts.loadedFontsCache.add(sig);
shouldBail = false;
}
}
if (shouldBail) {
return;
}
let didUpdate = false;
const elementsMap = this.scene.getNonDeletedElementsMap();
for (const element of this.scene.getNonDeletedElements()) {
if (isTextElement(element)) {
didUpdate = true;
ShapeCache.delete(element);
// clear the width cache, so that we don't perform subsequent wrapping based on the stale fallback font metrics
charWidth.clearCache(getFontString(element));
const container = getContainerElement(element, elementsMap);
if (container) {
ShapeCache.delete(container);
}
}
}
if (didUpdate) {
this.scene.triggerUpdate();
}
};
/**
* Load font faces for a given scene and trigger scene update.
*
* FontFaceSet loadingdone event we listen on may not always
* fire (looking at you Safari), so on init we manually load all
* fonts and rerender scene text elements once done.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
: undefined;
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/
public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(elements)
: undefined;
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
};
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/**
* Generate CSS @font-face declarations for the given elements.
*/
public static async generateFontFaceDeclarations(
elements: readonly ExcalidrawElement[],
) {
const families = Fonts.getUniqueFamilies(elements);
const charsPerFamily = Fonts.getCharsPerFamily(elements);
// for simplicity, assuming we have just one family with the CJK handdrawn fallback
const familyWithCJK = families.find((x) =>
getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT),
);
if (familyWithCJK) {
const characters = Fonts.getCharacters(charsPerFamily, familyWithCJK);
if (containsCJK(characters)) {
const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT];
// adding the same characters to the CJK handrawn family
charsPerFamily[family] = new Set(characters);
// the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order
// so that they get overriden with the later defined font faces, i.e. in case they share some codepoints
families.unshift(FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]);
}
}
// don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.),
// instead go three requests at a time, in a controlled manner, without completely blocking the main thread
// and avoiding potential issues such as rate limits
const iterator = Fonts.fontFacesStylesGenerator(families, charsPerFamily);
const concurrency = 3;
const fontFaces = await new PromisePool(iterator, concurrency).all();
// dedup just in case (i.e. could be the same font faces with 0 glyphs)
return Array.from(new Set(fontFaces));
}
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) {
continue;
}
for (const { fontFace } of fontFaces) {
if (!window.document.fonts.has(fontFace)) {
window.document.fonts.add(fontFace);
}
}
}
// loading 10 font faces at a time, in a controlled manner
const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily);
const concurrency = 10;
const fontFaces = await new PromisePool(iterator, concurrency).all();
return fontFaces.flat().filter(Boolean);
}
private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({
fontFamily,
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
const text =
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => {
try {
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
const fontFaces = await window.document.fonts.load(font, text);
return [index, fontFaces];
} catch (e) {
// don't let it all fail if just one font fails to load
console.error(
`Failed to load font "${font}" from urls "${Fonts.registered
.get(fontFamily)
?.fontFaces.map((x) => x.urls)}"`,
e,
);
}
});
}
}
}
private static *fontFacesStylesGenerator(
families: Array<number>,
charsPerFamily: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, string]>> {
for (const [familyIndex, family] of families.entries()) {
const { fontFaces, metadata } = Fonts.registered.get(family) ?? {};
if (!Array.isArray(fontFaces)) {
console.error(
`Couldn't find registered fonts for font-family "${family}"`,
Fonts.registered,
);
continue;
}
if (metadata?.local) {
// don't inline local fonts
continue;
}
for (const [fontFaceIndex, fontFace] of fontFaces.entries()) {
yield promiseTry(async () => {
try {
const characters = Fonts.getCharacters(charsPerFamily, family);
const fontFaceCSS = await fontFace.toCSS(characters);
if (!fontFaceCSS) {
return;
}
// giving a buffer of 10K font faces per family
const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex;
const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const;
return fontFaceTuple;
} catch (error) {
console.error(
`Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`,
error,
);
}
});
}
}
}
/**
* Register a new font.
*
* @param family font family
* @param metadata font metadata
* @param fontFacesDecriptors font faces descriptors
*/
private static register(
this:
| Fonts
| {
registered: Map<
number,
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
>;
},
family: string,
metadata: FontMetadata,
...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
) {
// TODO: likely we will need to abandon number value in order to support custom fonts
const fontFamily =
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
const registeredFamily = this.registered.get(fontFamily);
if (!registeredFamily) {
this.registered.set(fontFamily, {
metadata,
fontFaces: fontFacesDecriptors.map(
({ uri, descriptors }) =>
new ExcalidrawFontFace(family, uri, descriptors),
),
});
}
return this.registered;
}
/**
* WARN: should be called just once on init, even across multiple instances.
*/
private static init() {
const fonts = {
registered: new Map<
ValueOf<typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS>,
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
>(),
};
const init = (
family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS,
...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[]
) => {
const fontFamily =
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
// default to Excalifont metrics
const metadata =
FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont];
Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors);
};
init("Cascadia", ...CascadiaFontFaces);
init("Comic Shanns", ...ComicShannsFontFaces);
init("Excalifont", ...ExcalifontFontFaces);
// keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
init("Helvetica", ...HelveticaFontFaces);
// used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
init("Liberation Sans", ...LiberationFontFaces);
init("Lilita One", ...LilitaFontFaces);
init("Nunito", ...NunitoFontFaces);
init("Virgil", ...VirgilFontFaces);
// fallback font faces
init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces);
init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces);
Fonts._initialized = true;
return fonts.registered;
}
/**
* Get all the unique font families for the given elements.
*/
private static getUniqueFamilies(
elements: ReadonlyArray<ExcalidrawElement>,
): Array<ExcalidrawTextElement["fontFamily"]> {
return Array.from(
elements.reduce((families, element) => {
if (isTextElement(element)) {
families.add(element.fontFamily);
}
return families;
}, new Set<number>()),
);
}
/**
* Get all the unique characters per font family for the given scene.
*/
private static getCharsPerFamily(
elements: ReadonlyArray<ExcalidrawElement>,
): Record<number, Set<string>> {
const charsPerFamily: Record<number, Set<string>> = {};
for (const element of elements) {
if (!isTextElement(element)) {
continue;
}
// gather unique codepoints only when inlining fonts
for (const char of element.originalText) {
if (!charsPerFamily[element.fontFamily]) {
charsPerFamily[element.fontFamily] = new Set();
}
charsPerFamily[element.fontFamily].add(char);
}
}
return charsPerFamily;
}
/**
* Get characters for a given family.
*/
private static getCharacters(
charsPerFamily: Record<number, Set<string>>,
family: number,
) {
return charsPerFamily[family]
? Array.from(charsPerFamily[family]).join("")
: "";
}
/**
* Get all registered font families.
*/
private static getAllFamilies() {
return Array.from(Fonts.registered.keys());
}
}
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Virgil].metrics;
const fontSizeEm = fontSize / unitsPerEm;
const lineGap =
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
/**
* Gets line height forr a selected family.
*/
export const getLineHeight = (fontFamily: FontFamilyValues) => {
const { lineHeight } =
Fonts.registered.get(fontFamily)?.metadata.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
return lineHeight as ExcalidrawTextElement["lineHeight"];
};
export interface ExcalidrawFontFaceDescriptor {
uri: string;
descriptors?: FontFaceDescriptors;
}
@@ -1,9 +0,0 @@
import { LOCAL_FONT_PROTOCOL } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const HelveticaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LOCAL_FONT_PROTOCOL,
},
];
@@ -1,9 +0,0 @@
import LiberationSansRegular from "./LiberationSans-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const LiberationFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LiberationSansRegular,
},
];
-17
View File
@@ -1,17 +0,0 @@
import LilitaLatin from "./Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
import LilitaLatinExt from "./Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
import { GOOGLE_FONTS_RANGES } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const LilitaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: LilitaLatinExt,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT },
},
{
uri: LilitaLatin,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN },
},
];
-38
View File
@@ -1,38 +0,0 @@
import Latin from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
import LatinExt from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
import Cyrilic from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
import CyrilicExt from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
import Vietnamese from "./Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
import { GOOGLE_FONTS_RANGES } from "../FontMetadata";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const NunitoFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: CyrilicExt,
descriptors: {
unicodeRange: GOOGLE_FONTS_RANGES.CYRILIC_EXT,
weight: "500",
},
},
{
uri: Cyrilic,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.CYRILIC, weight: "500" },
},
{
uri: Vietnamese,
descriptors: {
unicodeRange: GOOGLE_FONTS_RANGES.VIETNAMESE,
weight: "500",
},
},
{
uri: LatinExt,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN_EXT, weight: "500" },
},
{
uri: Latin,
descriptors: { unicodeRange: GOOGLE_FONTS_RANGES.LATIN, weight: "500" },
},
];
@@ -1,9 +0,0 @@
import Virgil from "./Virgil-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const VirgilFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: Virgil,
},
];

Some files were not shown because too many files have changed in this diff Show More