Compare commits

..

2 Commits

Author SHA1 Message Date
Mark Tolmacs 521896cccf Fix lint 2024-09-23 12:47:04 +02:00
Mark Tolmacs fe318126bd Warn to save content
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-23 12:12:58 +02:00
437 changed files with 3853 additions and 23474 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_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com 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 VITE_APP_AI_BACKEND=http://localhost:3015
@@ -37,14 +37,3 @@ VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint # Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true 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_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_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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v3.0.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img <img
src={require("@site/static/img/welcome-screen-overview.png").default} 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 ### Center
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description | | Font Family | Description |
| ----------- | ---------------------- | | ----------- | ---------------------- |
| `Virgil` | The `Hand-drawn` font | | `Virgil` | The `handwritten` font |
| `Helvetica` | The `Normal` Font | | `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font | | `Cascadia` | The `Code` Font |
+29 -41
View File
@@ -369,12 +369,10 @@ export default function ExampleApp({
return false; return false;
} }
await exportToClipboard({ await exportToClipboard({
data: { elements: excalidrawAPI.getSceneElements(),
elements: excalidrawAPI.getSceneElements(), appState: excalidrawAPI.getAppState(),
appState: excalidrawAPI.getAppState(), files: excalidrawAPI.getFiles(),
files: excalidrawAPI.getFiles(), type,
},
type: "json",
}); });
window.alert(`Copied to clipboard as ${type} successfully`); window.alert(`Copied to clipboard as ${type} successfully`);
}; };
@@ -819,17 +817,15 @@ export default function ExampleApp({
return; return;
} }
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: excalidrawAPI?.getSceneElements(),
elements: excalidrawAPI?.getSceneElements(), appState: {
appState: { ...initialData.appState,
...initialData.appState, exportWithDarkMode,
exportWithDarkMode, exportEmbedScene,
exportEmbedScene, width: 300,
width: 300, height: 100,
height: 100,
},
files: excalidrawAPI?.getFiles(),
}, },
files: excalidrawAPI?.getFiles(),
}); });
appRef.current.querySelector(".export-svg").innerHTML = appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML; svg.outerHTML;
@@ -845,18 +841,14 @@ export default function ExampleApp({
return; return;
} }
const blob = await exportToBlob({ const blob = await exportToBlob({
data: { elements: excalidrawAPI?.getSceneElements(),
elements: excalidrawAPI?.getSceneElements(), mimeType: "image/png",
appState: { appState: {
...initialData.appState, ...initialData.appState,
exportEmbedScene, exportEmbedScene,
exportWithDarkMode, exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
},
config: {
mimeType: "image/png",
}, },
files: excalidrawAPI?.getFiles(),
}); });
setBlobUrl(window.URL.createObjectURL(blob)); setBlobUrl(window.URL.createObjectURL(blob));
}} }}
@@ -872,14 +864,12 @@ export default function ExampleApp({
return; return;
} }
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements: excalidrawAPI.getSceneElements(),
elements: excalidrawAPI.getSceneElements(), appState: {
appState: { ...initialData.appState,
...initialData.appState, exportWithDarkMode,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
}, },
files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont"; ctx.font = "30px Excalifont";
@@ -895,14 +885,12 @@ export default function ExampleApp({
return; return;
} }
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements: excalidrawAPI.getSceneElements(),
elements: excalidrawAPI.getSceneElements(), appState: {
appState: { ...initialData.appState,
...initialData.appState, exportWithDarkMode,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
}, },
files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont"; ctx.font = "30px Excalifont";
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
# copied assets # copied assets
public/**/*.woff2 public/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "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", "dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspace && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
@@ -1,2 +1,2 @@
# copied assets # copied assets
public/**/*.woff2 public/*.woff2
@@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "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", "start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build", "build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
+6 -358
View File
@@ -25,13 +25,7 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
exportToCanvas,
exportToSvg,
} from "../packages/excalidraw"; } from "../packages/excalidraw";
import {
exportToBlob,
getNonDeletedElements,
} from "../packages/excalidraw/index";
import type { import type {
AppState, AppState,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
@@ -132,10 +126,8 @@ import DebugCanvas, {
loadSavedDebugState, loadSavedDebugState,
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; import type { SaveWarningRef } from "./components/SaveWarning";
import { fileSave } from "../packages/excalidraw/data/filesystem"; import { SaveWarning } from "./components/SaveWarning";
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
import { round } from "../packages/math";
polyfill(); polyfill();
@@ -341,6 +333,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
const activityRef = useRef<SaveWarningRef | null>(null);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -616,19 +610,6 @@ const ExcalidrawWrapper = () => {
}; };
}, [excalidrawAPI]); }, [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 = ( const onChange = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -638,83 +619,7 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements); collabAPI.syncElements(elements);
} }
const nonDeletedElements = getNonDeletedElements(elements); activityRef.current?.activity();
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 // this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time // not to evaludate the nested expression every time
@@ -957,6 +862,7 @@ const ExcalidrawWrapper = () => {
setTheme={(theme) => setAppTheme(theme)} setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<SaveWarning ref={activityRef} />
<AppWelcomeScreen <AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen} onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
@@ -1221,269 +1127,11 @@ const ExcalidrawWrapper = () => {
/> />
)} )}
</Excalidraw> </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> </div>
); );
}; };
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}> <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 throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import type { import type {
BinaryFileData,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
SocketId, SocketId,
} from "../../packages/excalidraw/types"; } 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 { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types"; } from "../../packages/excalidraw/element/types";
@@ -159,7 +157,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
throw new AbortError(); throw new AbortError();
} }
const { savedFiles, erroredFiles } = await saveFilesToFirebase({ return saveFilesToFirebase({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`, prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({ files: await encodeFilesForUpload({
files: addedFiles, files: addedFiles,
@@ -167,29 +165,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
maxBytes: FILE_UPLOAD_MAX_BYTES, 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; this.excalidrawAPI = props.excalidrawAPI;
@@ -419,7 +394,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
.filter((element) => { .filter((element) => {
return ( return (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
!this.fileManager.isFileTracked(element.fileId) && !this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted && !element.isDeleted &&
(opts.forceFetchFiles (opts.forceFetchFiles
? element.status !== "pending" || ? element.status !== "pending" ||
+8 -12
View File
@@ -21,19 +21,15 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
data: { elements: children,
elements: children, appState: {
appState: { ...appState,
...appState, exportBackground: true,
exportBackground: true, viewBackgroundColor: appState.viewBackgroundColor,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+1 -1
View File
@@ -84,7 +84,7 @@ const _debugRenderer = (
scale, scale,
normalizedWidth, normalizedWidth,
normalizedHeight, normalizedHeight,
canvasBackgroundColor: "transparent", viewBackgroundColor: "transparent",
}); });
// Apply zoom // Apply zoom
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import { t } from "../../packages/excalidraw/i18n";
import { getShortcutKey } from "../../packages/excalidraw/utils";
export type SaveWarningRef = {
activity: () => Promise<void>;
};
export const SaveWarning = forwardRef<SaveWarningRef, {}>((props, ref) => {
const dialogRef = useRef<HTMLDivElement | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useImperativeHandle(ref, () => ({
/**
* Call this API method via the ref to hide warning message
* and start an idle timer again.
*/
activity: async () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
dialogRef.current?.classList.remove("animate");
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
dialogRef.current?.classList.add("animate");
}, 5000);
},
}));
return (
<div ref={dialogRef} className="alert-save">
<div className="dialog">
{t("alerts.saveYourContent", {
shortcut: getShortcutKey("CtrlOrCmd + S"),
})}
</div>
</div>
);
});
+21 -50
View File
@@ -16,26 +16,14 @@ import type {
BinaryFiles, BinaryFiles,
} from "../../packages/excalidraw/types"; } from "../../packages/excalidraw/types";
type FileVersion = Required<BinaryFileData>["version"];
export class FileManager { export class FileManager {
/** files being fetched */ /** files being fetched */
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>(); private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_fetch = new Map<
ExcalidrawImageElement["fileId"],
true
>();
/** files being saved */ /** files being saved */
private savingFiles = new Map< private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
ExcalidrawImageElement["fileId"],
FileVersion
>();
/* files already saved to persistent storage */ /* files already saved to persistent storage */
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>(); private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
private erroredFiles_save = new Map< private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
ExcalidrawImageElement["fileId"],
FileVersion
>();
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
@@ -49,8 +37,8 @@ export class FileManager {
erroredFiles: Map<FileId, true>; erroredFiles: Map<FileId, true>;
}>; }>;
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{ saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, true>;
}>; }>;
}) { }) {
this._getFiles = getFiles; 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 ( return (
this.savedFiles.has(id) || this.savedFiles.has(id) ||
this.savingFiles.has(id) ||
this.fetchingFiles.has(id) || this.fetchingFiles.has(id) ||
this.erroredFiles_fetch.has(id) || this.savingFiles.has(id) ||
this.erroredFiles_save.has(id) this.erroredFiles.has(id)
); );
}; };
isFileSavedOrBeingSaved = (file: BinaryFileData) => { isFileSaved = (id: FileId) => {
const fileVersion = this.getFileVersion(file); return this.savedFiles.has(id);
return (
this.savedFiles.get(file.id) === fileVersion ||
this.savingFiles.get(file.id) === fileVersion
);
};
getFileVersion = (file: BinaryFileData) => {
return file.version ?? 1;
}; };
saveFiles = async ({ saveFiles = async ({
@@ -92,16 +71,13 @@ export class FileManager {
const addedFiles: Map<FileId, BinaryFileData> = new Map(); const addedFiles: Map<FileId, BinaryFileData> = new Map();
for (const element of elements) { for (const element of elements) {
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if ( if (
fileData && isInitializedImageElement(element) &&
// NOTE if errored during save, won't retry due to this check files[element.fileId] &&
!this.isFileSavedOrBeingSaved(fileData) !this.isFileHandled(element.fileId)
) { ) {
addedFiles.set(element.fileId, files[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, addedFiles,
}); });
for (const [fileId, fileData] of savedFiles) { for (const [fileId] of savedFiles) {
this.savedFiles.set(fileId, this.getFileVersion(fileData)); this.savedFiles.set(fileId, true);
}
for (const [fileId, fileData] of erroredFiles) {
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
} }
return { return {
@@ -149,10 +121,10 @@ export class FileManager {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
for (const file of loadedFiles) { for (const file of loadedFiles) {
this.savedFiles.set(file.id, this.getFileVersion(file)); this.savedFiles.set(file.id, true);
} }
for (const [fileId] of erroredFiles) { for (const [fileId] of erroredFiles) {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles.set(fileId, true);
} }
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
@@ -188,7 +160,7 @@ export class FileManager {
): element is InitializedExcalidrawImageElement => { ): element is InitializedExcalidrawImageElement => {
return ( return (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
this.savedFiles.has(element.fileId) && this.isFileSaved(element.fileId) &&
element.status === "pending" element.status === "pending"
); );
}; };
@@ -197,8 +169,7 @@ export class FileManager {
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
this.erroredFiles_fetch.clear(); this.erroredFiles.clear();
this.erroredFiles_save.clear();
} }
} }
+4 -4
View File
@@ -183,8 +183,8 @@ export class LocalData {
); );
}, },
async saveFiles({ addedFiles }) { async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, BinaryFileData>(); const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, BinaryFileData>(); const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag // before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed // optimistically. Hopefully nothing fails, and an IDB read executed
@@ -195,10 +195,10 @@ export class LocalData {
[...addedFiles].map(async ([id, fileData]) => { [...addedFiles].map(async ([id, fileData]) => {
try { try {
await set(id, fileData, filesStore); await set(id, fileData, filesStore);
savedFiles.set(id, fileData); savedFiles.set(id, true);
} catch (error: any) { } catch (error: any) {
console.error(error); 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 firebase = await loadFirebaseStorage();
const erroredFiles: FileId[] = []; const erroredFiles = new Map<FileId, true>();
const savedFiles: FileId[] = []; const savedFiles = new Map<FileId, true>();
await Promise.all( await Promise.all(
files.map(async ({ id, buffer }) => { files.map(async ({ id, buffer }) => {
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
}, },
); );
savedFiles.push(id); savedFiles.set(id, true);
} catch (error: any) { } catch (error: any) {
erroredFiles.push(id); erroredFiles.set(id, true);
} }
}), }),
); );
+22 -9
View File
@@ -54,6 +54,12 @@
content="https://excalidraw.com/og-image-3.png" 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 --> <!-- to minimize white flash on load when user has dark mode enabled -->
<script> <script>
@@ -118,19 +124,28 @@
<!-- Following placeholder is replaced during the build step --> <!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS --> <!-- 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 { %> <% } else { %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = window.origin; window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- 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="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="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -212,7 +227,6 @@
</header> </header>
<div id="root"></div> <div id="root"></div>
<script type="module" src="index.tsx"></script> <script type="module" src="index.tsx"></script>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<!-- 100% privacy friendly analytics --> <!-- 100% privacy friendly analytics -->
<script> <script>
// need to load this script dynamically bcs. of iframe embed tracking // need to load this script dynamically bcs. of iframe embed tracking
@@ -245,6 +259,5 @@
} }
</script> </script>
<!-- end LEGACY GOOGLE ANALYTICS --> <!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body> </body>
</html> </html>
+37
View File
@@ -18,6 +18,43 @@
margin-inline-start: auto; margin-inline-start: auto;
} }
.alert-save {
position: absolute;
z-index: 10;
left: 0;
right: 0;
bottom: 10vh;
margin-inline: auto;
width: fit-content;
opacity: 0;
transition: all 0s;
&.animate {
opacity: 1;
transition: all 0.2s ease-in;
}
.dialog {
margin-inline: 10px;
padding: 1rem;
padding-inline: 1.25rem;
resize: none;
white-space: pre-wrap;
box-sizing: border-box;
background-color: var(--color-warning);
border-radius: var(--border-radius-md);
border: 1px solid var(--dialog-border-color);
font-size: 0.875rem;
text-align: center;
line-height: 1.5;
color: var(--color-text-warning);
}
}
.encrypted-icon { .encrypted-icon {
border-radius: var(--space-factor); border-radius: var(--space-factor);
color: var(--color-primary); color: var(--color-primary);
+7 -10
View File
@@ -23,20 +23,20 @@
] ]
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "1.13.1", "jotai": "1.13.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "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", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
@@ -49,8 +49,5 @@
"start:production": "yarn build && yarn serve", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o", "serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "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 // Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string; VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
VITE_APP_PLUS_LP: string; VITE_APP_PLUS_LP: string;
VITE_APP_PLUS_APP: string; VITE_APP_PLUS_APP: string;
+196 -218
View File
@@ -5,237 +5,215 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
export default defineConfig(({ mode }) => { // To load .env.local variables
// To load .env variables const envVars = loadEnv("", `../`);
const envVars = loadEnv(mode, `../`); // https://vitejs.dev/config/
// https://vitejs.dev/config/ export default defineConfig({
return { server: {
server: { port: Number(envVars.VITE_APP_PORT || 3000),
port: Number(envVars.VITE_APP_PORT || 3000), // open the browser
// open the browser open: true,
open: true, },
}, // We need to specify the envDir since now there are no
// 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
//more located in parallel with the vite.config.ts file but in parent dir envDir: "../",
envDir: "../", build: {
build: { outDir: "build",
outDir: "build", rollupOptions: {
rollupOptions: { output: {
output: { assetFileNames(chunkInfo) {
assetFileNames(chunkInfo) { if (chunkInfo?.name?.endsWith(".woff2")) {
if (chunkInfo?.name?.endsWith(".woff2")) { // put on root so we are flexible about the CDN path
const family = chunkInfo.name.split("-")[0]; return "[name]-[hash][extname]";
return `fonts/${family}/[name][extname]`; }
}
return "assets/[name]-[hash][extname]"; return "assets/[name]-[hash][extname]";
}, },
// Creating separate chunk for locales except for en and percentages.json so they // Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with // can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load // 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. // 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) { manualChunks(id) {
if ( if (
id.includes("packages/excalidraw/locales") && id.includes("packages/excalidraw/locales") &&
id.match(/en.json|percentages.json/) === null id.match(/en.json|percentages.json/) === null
) { ) {
const index = id.indexOf("locales/"); const index = id.indexOf("locales/");
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
},
}, },
}, },
sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
}, },
plugins: [ sourcemap: true,
Sitemap({ },
hostname: "https://excalidraw.com", plugins: [
outDir: "build", woff2BrowserPlugin(),
changefreq: "monthly", react(),
// its static in public folder checker({
generateRobotsTxt: false, typescript: true,
}), eslint:
woff2BrowserPlugin(), envVars.VITE_APP_ENABLE_ESLINT === "false"
react(), ? undefined
checker({ : { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
typescript: true, overlay: {
eslint: initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
envVars.VITE_APP_ENABLE_ESLINT === "false" badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
? undefined },
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' }, }),
overlay: { svgrPlugin(),
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false", ViteEjsPlugin(),
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem", VitePWA({
}, registerType: "autoUpdate",
}), devOptions: {
svgrPlugin(), /* set this flag to true to enable in Development mode */
ViteEjsPlugin(), enabled: false,
VitePWA({ },
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
},
workbox: { workbox: {
// don't precache fonts, locales and separate chunks // Don't push fonts, locales and wasm to app precache
globIgnores: [ globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
"fonts.css", runtimeCaching: [
"**/locales/**", {
"service-worker.js", urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
"**/*.chunk-*.js", handler: "CacheFirst",
], options: {
runtimeCaching: [ cacheName: "fonts",
{ expiration: {
urlPattern: new RegExp(".+.woff2"), maxEntries: 50,
handler: "CacheFirst", maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
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],
},
}, },
}, },
{
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: [ {
{ urlPattern: new RegExp("fonts.css"),
src: "/screenshots/virtual-whiteboard.png", handler: "StaleWhileRevalidate",
type: "image/png", options: {
sizes: "462x945", cacheName: "fonts",
expiration: {
maxEntries: 50,
},
}, },
{ },
src: "/screenshots/wireframe.png", {
type: "image/png", urlPattern: new RegExp("locales/[^/]+.js"),
sizes: "462x945", handler: "CacheFirst",
options: {
cacheName: "locales",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
},
}, },
{ },
src: "/screenshots/illustration.png", {
type: "image/png", urlPattern: new RegExp(".wasm-.+.js"),
sizes: "462x945", 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", share_target: {
sizes: "462x945", action: "/web-share-target",
}, method: "POST",
{ enctype: "multipart/form-data",
src: "/screenshots/export.png", params: {
type: "image/png", files: [
sizes: "462x945", {
}, name: "file",
], accept: [
"application/vnd.excalidraw+json",
"application/json",
".excalidraw",
],
},
],
},
}, },
}), screenshots: [
createHtmlPlugin({ {
minify: true, src: "/screenshots/virtual-whiteboard.png",
}), type: "image/png",
], sizes: "462x945",
publicDir: "../public", },
}; {
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" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": "18.0.0 - 20.x.x"
}, },
"homepage": ".", "homepage": ".",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
@@ -55,9 +55,15 @@
"build:app": "yarn --cwd ./excalidraw-app build:app", "build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version", "build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build", "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": "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:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest", "test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -68,15 +74,9 @@
"test:coverage": "vitest --coverage", "test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch", "test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true", "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", "autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js", "prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js", "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: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", "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
-4
View File
@@ -15,10 +15,6 @@ Please add the latest change on the top under the correct section.
### Features ### 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) - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
+13 -35
View File
@@ -9,9 +9,8 @@ import {
readSystemClipboard, readSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportAsImage } from "../data/index"; import { exportCanvas, prepareElementsForExport } from "../data/index";
import { getTextFromElements, isTextElement } from "../element"; import { getTextFromElements, isTextElement } from "../element";
import { prepareElementsForExport } from "../data/index";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -137,41 +136,25 @@ export const actionCopyAsSvg = register({
); );
try { try {
await exportAsImage({ await exportCanvas(
type: "clipboard-svg", "clipboard-svg",
data: { elements: exportedElements, appState, files: app.files }, exportedElements,
config: { appState,
app.files,
{
...appState, ...appState,
exportingFrame, exportingFrame,
name: app.getName(), name: app.getName(),
}, },
}); );
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return { 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, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -207,16 +190,11 @@ export const actionCopyAsPng = register({
true, true,
); );
try { try {
await exportAsImage({ await exportCanvas("clipboard", exportedElements, appState, app.files, {
type: "clipboard", ...appState,
data: { elements: exportedElements, appState, files: app.files }, exportingFrame,
config: { name: app.getName(),
...appState,
exportingFrame,
name: appState.name || app.getName(),
},
}); });
return { return {
appState: { appState: {
...appState, ...appState,
@@ -1,55 +0,0 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});
+2 -14
View File
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem"; 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 { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement, Theme } from "../element/types"; import type { Theme } from "../element/types";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -58,18 +58,6 @@ export const actionChangeExportScale = register({
? getSelectedElements(elements, appState) ? getSelectedElements(elements, appState)
: elements; : 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 ( return (
<> <>
{EXPORT_SCALES.map((s) => { {EXPORT_SCALES.map((s) => {
@@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { pointFrom } from "../../math"; import { point } from "../../math";
import { isPathALoop } from "../shapes"; import { isPathALoop } from "../shapes";
export const actionFinalize = register({ export const actionFinalize = register({
@@ -115,7 +115,7 @@ export const actionFinalize = register({
mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: linePoints.map((p, index) => points: linePoints.map((p, index) =>
index === linePoints.length - 1 index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1]) ? point(firstPoint[0], firstPoint[1])
: p, : p,
), ),
}); });
@@ -2,7 +2,7 @@ import React from "react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { pointFrom } from "../../math"; import { point } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window; const { h } = window;
@@ -50,11 +50,11 @@ describe("flipping re-centers selection", () => {
startArrowhead: null, startArrowhead: null,
endArrowhead: "arrow", endArrowhead: "arrow",
points: [ points: [
pointFrom(0, 0), point(0, 0),
pointFrom(0, -35), point(0, -35),
pointFrom(-90.9, -35), point(-90.9, -35),
pointFrom(-90.9, 204.9), point(-90.9, 204.9),
pointFrom(65.1, 204.9), point(65.1, 204.9),
], ],
elbowed: true, elbowed: true,
}), }),
@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history"; import type { History } from "../history";
import { HistoryChangedEvent } from "../history"; import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { KEYS, matchKey } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types"; import type { SceneElementsMap } from "../element/types";
@@ -63,7 +63,9 @@ export const createUndoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => 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 }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -102,8 +104,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] &&
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -116,7 +116,7 @@ import {
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math"; import { point, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1651,7 +1651,7 @@ export const actionChangeArrowType = register({
elementsMap, elementsMap,
[finalStartPoint, finalEndPoint].map( [finalStartPoint, finalEndPoint].map(
(p): LocalPoint => (p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y), point(p[0] - newElement.x, p[1] - newElement.y),
), ),
vector(0, 0), vector(0, 0),
{ {
-2
View File
@@ -88,5 +88,3 @@ export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";
+2
View File
@@ -23,6 +23,7 @@ export type ShortcutName =
| "sendToBack" | "sendToBack"
| "bringToFront" | "bringToFront"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg"
| "group" | "group"
| "ungroup" | "ungroup"
| "gridMode" | "gridMode"
@@ -87,6 +88,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"), : getShortcutKey("CtrlOrCmd+Shift+]"),
], ],
copyAsPng: [getShortcutKey("Shift+Alt+C")], copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")], group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")], gridMode: [getShortcutKey("CtrlOrCmd+'")],
+6 -3
View File
@@ -10,6 +10,7 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store"; import type { StoreActionType } from "../store";
export type ActionSource = export type ActionSource =
@@ -23,7 +24,10 @@ export type ActionSource =
export type ActionResult = export type ActionResult =
| { | {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Partial<AppState> | null; appState?: MarkOptional<
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
storeAction: StoreActionType; storeAction: StoreActionType;
replaceFiles?: boolean; replaceFiles?: boolean;
@@ -134,8 +138,7 @@ export type ActionName =
| "commandPalette" | "commandPalette"
| "autoResize" | "autoResize"
| "elementStats" | "elementStats"
| "searchMenu" | "searchMenu";
| "cropEditor";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+4 -9
View File
@@ -1,18 +1,17 @@
import { COLOR_PALETTE } from "./colors";
import { import {
ARROW_TYPE, ARROW_TYPE,
COLOR_WHITE,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
DEFAULT_ZOOM_VALUE,
EXPORT_SCALES, EXPORT_SCALES,
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
} from "./constants"; } from "./constants";
import type { AppState } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio ? devicePixelRatio
@@ -100,10 +99,10 @@ export const getDefaultAppState = (): Omit<
editingFrame: null, editingFrame: null,
elementsToHighlight: null, elementsToHighlight: null,
toast: null, toast: null,
viewBackgroundColor: COLOR_WHITE, viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { zoom: {
value: DEFAULT_ZOOM_VALUE, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElementId: null,
@@ -117,8 +116,6 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [], searchMatches: [],
}; };
}; };
@@ -240,8 +237,6 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false }, userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
}); });
+1 -32
View File
@@ -17,16 +17,13 @@ import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement, isBindableElement,
isBoundToContainer, isBoundToContainer,
isImageElement,
isTextElement, isTextElement,
} from "./element/typeChecks"; } from "./element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
@@ -629,18 +626,6 @@ export class AppStateChange implements Change<AppState> {
); );
break; break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId": case "editingGroupId":
const editingGroupId = nextAppState[key]; const editingGroupId = nextAppState[key];
@@ -771,7 +756,6 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds, selectedElementIds,
editingLinearElementId, editingLinearElementId,
selectedLinearElementId, selectedLinearElementId,
croppingElementId,
...standaloneProps ...standaloneProps
} = delta as ObservedAppState; } = delta as ObservedAppState;
@@ -795,10 +779,7 @@ export class AppStateChange implements Change<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
ElementUpdate<Ordered<T>>,
"seed"
>;
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1235,18 +1216,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
}); });
} }
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change // strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
+12 -9
View File
@@ -1,8 +1,11 @@
import type { Radians } from "../math"; import type { Radians } from "../math";
import { pointFrom } from "../math"; import { point } from "../math";
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors"; import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import { import {
COLOR_CHARCOAL_BLACK,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
VERTICAL_ALIGN, VERTICAL_ALIGN,
@@ -170,7 +173,7 @@ const commonProps = {
fontSize: DEFAULT_FONT_SIZE, fontSize: DEFAULT_FONT_SIZE,
opacity: 100, opacity: 100,
roughness: 1, roughness: 1,
strokeColor: COLOR_CHARCOAL_BLACK, strokeColor: COLOR_PALETTE.black,
roundness: null, roundness: null,
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
@@ -257,7 +260,7 @@ const chartLines = (
x, x,
y, y,
width: chartWidth, width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [point(0, 0), point(chartWidth, 0)],
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -268,7 +271,7 @@ const chartLines = (
x, x,
y, y,
height: chartHeight, height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], points: [point(0, 0), point(0, -chartHeight)],
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -281,7 +284,7 @@ const chartLines = (
strokeStyle: "dotted", strokeStyle: "dotted",
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [point(0, 0), point(chartWidth, 0)],
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -321,7 +324,7 @@ const chartBaseElements = (
y: y - chartHeight, y: y - chartHeight,
width: chartWidth, width: chartWidth,
height: chartHeight, height: chartHeight,
strokeColor: COLOR_CHARCOAL_BLACK, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
}) })
@@ -438,7 +441,7 @@ const chartTypeLine = (
height: cy, height: cy,
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)], points: [point(0, 0), point(0, cy)],
}); });
}); });
+23 -21
View File
@@ -1,25 +1,27 @@
import oc from "open-color"; import oc from "open-color";
import { import type { Merge } from "./utility-types";
COLOR_WHITE,
COLOR_CHARCOAL_BLACK, // FIXME can't put to utils.ts rn because of circular dependency
COLOR_TRANSPARENT, const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
} from "./constants"; source: R,
import { type Merge } from "./utility-types"; keys: K,
import { pick } from "./utils"; ) => {
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 = export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime" | "black"> | Exclude<keyof oc, "indigo" | "lime">
| "transparent" | "transparent"
| "charcoal"
| "bronze"; | "bronze";
export type ColorTuple = readonly [string, string, string, string, string]; export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge< export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>, Record<ColorPickerColor, ColorTuple>,
{ { black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
charcoal: typeof COLOR_CHARCOAL_BLACK;
white: typeof COLOR_WHITE;
transparent: typeof COLOR_TRANSPARENT;
}
>; >;
// used general type instead of specific type (ColorPalette) to support custom colors // 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 = ( export const getSpecificColorShades = (
color: Exclude< color: Exclude<
ColorPickerColor, ColorPickerColor,
"transparent" | "charcoal" | "black" | "white" | "bronze" "transparent" | "white" | "black" | "bronze"
>, >,
indexArr: Readonly<ColorShadesIndexes>, indexArr: Readonly<ColorShadesIndexes>,
) => { ) => {
@@ -47,9 +49,9 @@ export const getSpecificColorShades = (
}; };
export const COLOR_PALETTE = { export const COLOR_PALETTE = {
transparent: COLOR_TRANSPARENT, transparent: "transparent",
charcoal: COLOR_CHARCOAL_BLACK, black: "#1e1e1e",
white: COLOR_WHITE, white: "#ffffff",
// open-colors // open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES), gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", 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 // ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [ export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.charcoal, COLOR_PALETTE.black,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[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, transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white, white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray, gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal, black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze, bronze: COLOR_PALETTE.bronze,
// rest // rest
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
@@ -134,7 +136,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent, transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white, white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray, gray: COLOR_PALETTE.gray,
charcoal: COLOR_PALETTE.charcoal, black: COLOR_PALETTE.black,
bronze: COLOR_PALETTE.bronze, bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
@@ -26,7 +26,6 @@ import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow, isElbowArrow,
isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -128,11 +127,6 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) && isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]); !isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -251,7 +245,6 @@ export const SelectedShapeActions = ({
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")} {showLineEditorAction && renderAction("toggleLinearEditor")}
</div> </div>
</fieldset> </fieldset>
File diff suppressed because it is too large Load Diff
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
}); });
if (!baseColorName) { if (!baseColorName) {
onChange(COLOR_PALETTE.charcoal); onChange(COLOR_PALETTE.black);
} }
} }
@@ -279,7 +279,6 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize, actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize, actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor, actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink, actionLink,
].map((action: Action) => ].map((action: Action) =>
actionToCommand( actionToCommand(
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont, value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon, icon: FreedrawIcon,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
testId: "font-family-hand-drawn", testId: "font-family-handrawn",
}, },
{ {
value: FONT_FAMILY.Nunito, value: FONT_FAMILY.Nunito,
@@ -21,7 +21,6 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts"; import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types"; import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor { export interface FontDescriptor {
value: number; value: number;
@@ -63,14 +62,12 @@ export const FontPickerList = React.memo(
const allFonts = useMemo( const allFonts = useMemo(
() => () =>
Array.from(Fonts.registered.entries()) Array.from(Fonts.registered.entries())
.filter( .filter(([_, { metadata }]) => !metadata.serverSide)
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback, .map(([familyId, { metadata, fonts }]) => {
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: metadata.icon ?? FontFamilyNormalIcon, icon: metadata.icon,
text: fontFaces[0]?.fontFace?.family ?? "Unknown", text: fonts[0].fontFace.family,
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -92,7 +89,7 @@ export const FontPickerList = React.memo(
); );
const sceneFamilies = useMemo( 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 // cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily], [selectedFontFamily],
+1 -11
View File
@@ -22,7 +22,7 @@ const Header = () => (
</a> </a>
<a <a
className="HelpDialog__btn" className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog" href="https://blog.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -222,16 +222,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]} ]}
isOr={false} isOr={false}
/> />
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} /> <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
@@ -100,14 +100,6 @@ const getHints = ({
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (appState.croppingElementId) {
return t("hints.leaveCropEditor");
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor");
}
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.selectionElement && appState.selectionElement &&
@@ -23,6 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types"; import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -35,7 +36,6 @@ import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils"; import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data"; import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator"; import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { exportToCanvas } from "../scene/export";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -123,25 +123,19 @@ const ImageExportModal = ({
} }
exportToCanvas({ exportToCanvas({
data: { elements: exportedElements,
elements: exportedElements, appState: {
appState: { ...appStateSnapshot,
...appStateSnapshot, name: projectName,
name: projectName, exportBackground: exportWithBackground,
exportEmbedScene: embedScene, exportWithDarkMode: exportDarkMode,
}, exportScale,
files, exportEmbedScene: embedScene,
},
config: {
canvasBackgroundColor: !exportWithBackground
? false
: appStateSnapshot.viewBackgroundColor,
padding: DEFAULT_EXPORT_PADDING,
theme: exportDarkMode ? "dark" : "light",
scale: exportScale,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null); setRenderError(null);
@@ -1,9 +1,9 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react"; import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts"; import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts";
import type { ChartType } from "../element/types"; import type { ChartType } from "../element/types";
import { COLOR_WHITE } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types"; import type { UIAppState } from "../types";
@@ -41,16 +41,14 @@ const ChartPreviewBtn = (props: {
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
svg = await exportToSvg({ svg = await exportToSvg(
data: { elements,
elements, {
appState: { exportBackground: false,
exportBackground: false, viewBackgroundColor: oc.white,
viewBackgroundColor: COLOR_WHITE,
},
files: null,
}, },
}); null, // files
);
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
@@ -7,8 +7,8 @@ import { t } from "../i18n";
import Trans from "./Trans"; import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types"; import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export";
import { import {
COLOR_WHITE,
EDITOR_LS_KEYS, EDITOR_LS_KEYS,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@@ -24,7 +24,6 @@ import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss"; import "./PublishLibrary.scss";
import { exportToCanvas, exportToSvg } from "../scene/export";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@@ -56,20 +55,16 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.fillStyle = COLOR_WHITE; ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items // draw items
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
data: { elements: item.elements,
elements: item.elements, files: null,
files: null, maxWidthOrHeight: BOX_SIZE,
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -131,15 +126,14 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
data: { elements: libItem.elements,
elements: libItem.elements, appState: {
appState: { ...appState,
...appState, viewBackgroundColor: OpenColor.white,
viewBackgroundColor: COLOR_WHITE, exportBackground: true,
exportBackground: true,
},
files: null,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -294,7 +294,6 @@ export const SearchMenu = () => {
// as well as to handle events before App ones // as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, { return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true, capture: true,
passive: false,
}); });
}, [setAppState, stableState, app]); }, [setAppState, stableState, app]);
@@ -20,7 +20,7 @@ import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { pointFrom, type GlobalPoint } from "../../../math"; import { point, type GlobalPoint } from "../../../math";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
@@ -69,6 +69,7 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false); mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
@@ -78,7 +79,7 @@ const resizeElementInGroup = (
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
pointFrom(x1, y1), point(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
pointFrom(x1, y1), point(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -13,7 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math"; import { point, pointRotateRads } from "../../../math";
interface MultiPositionProps { interface MultiPositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -44,8 +44,8 @@ const moveElements = (
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y), point(origElement.x, origElement.y),
pointFrom(cx, cy), point(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -97,8 +97,8 @@ const moveGroupTo = (
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(latestElement.x, latestElement.y), point(latestElement.x, latestElement.y),
pointFrom(cx, cy), point(cx, cy),
latestElement.angle, latestElement.angle,
); );
@@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y), point(origElement.x, origElement.y),
pointFrom(cx, cy), point(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -241,8 +241,8 @@ const MultiPosition = ({
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(el.x, el.y), point(el.x, el.y),
pointFrom(cx, cy), point(cx, cy),
el.angle, el.angle,
); );
@@ -4,7 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { pointFrom, pointRotateRads } from "../../../math"; import { point, pointRotateRads } from "../../../math";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -33,8 +33,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(origElement.x, origElement.y), point(origElement.x, origElement.y),
pointFrom(cx, cy), point(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -93,8 +93,8 @@ const Position = ({
appState, appState,
}: PositionProps) => { }: PositionProps) => {
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(element.x, element.y), point(element.x, element.y),
pointFrom(element.x + element.width / 2, element.y + element.height / 2), point(element.x + element.width / 2, element.y + element.height / 2),
element.angle, element.angle,
); );
const value = const value =
@@ -25,7 +25,7 @@ import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math"; import type { Degrees } from "../../../math";
import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math"; import { degreesToRadians, point, pointRotateRads } from "../../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45); testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = pointRotateRads( let [newTopLeftX, newTopLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66); testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = pointRotateRads( [newTopLeftX, newTopLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
expect(newTopLeftX.toString()).not.toEqual(xInput.value); expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
testInputProperty(rectangle, "width", "W", rectangle.width, 400); testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
let [currentTopLeftX, currentTopLeftY] = pointRotateRads( let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
[currentTopLeftX, currentTopLeftY] = pointRotateRads( [currentTopLeftX, currentTopLeftY] = pointRotateRads(
pointFrom(rectangle.x, rectangle.y), point(rectangle.x, rectangle.y),
pointFrom(cx, cy), point(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -1,5 +1,5 @@
import type { Radians } from "../../../math"; import type { Radians } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math"; import { point, pointRotateRads } from "../../../math";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
updateBoundElements, updateBoundElements,
@@ -151,6 +151,8 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight); nextHeight = Math.max(nextHeight, minHeight);
} }
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement( mutateElement(
latestElement, latestElement,
{ {
@@ -199,7 +201,7 @@ export const resizeElement = (
} }
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, {
newSize: { width: nextWidth, height: nextHeight }, oldSize: { width: oldWidth, height: oldHeight },
}); });
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
@@ -229,8 +231,8 @@ export const moveElement = (
originalElement.y + originalElement.height / 2, originalElement.y + originalElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(originalElement.x, originalElement.y), point(originalElement.x, originalElement.y),
pointFrom(cx, cy), point(cx, cy),
originalElement.angle, originalElement.angle,
); );
@@ -238,8 +240,8 @@ export const moveElement = (
const changeInY = newTopLeftY - topLeftY; const changeInY = newTopLeftY - topLeftY;
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom(newTopLeftX, newTopLeftY), point(newTopLeftX, newTopLeftY),
pointFrom(cx + changeInX, cy + changeInY), point(cx + changeInX, cy + changeInY),
-originalElement.angle as Radians, -originalElement.angle as Radians,
); );
@@ -91,16 +91,12 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
data: { elements: data.current.elements,
elements: data.current.elements, files: data.current.files,
files: data.current.files, exportPadding: DEFAULT_EXPORT_PADDING,
}, maxWidthOrHeight:
config: { Math.max(parent.offsetWidth, parent.offsetHeight) *
padding: DEFAULT_EXPORT_PADDING, window.devicePixelRatio,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
},
}); });
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big) // likely prevent preview and export (e.g. canvas too big)
@@ -203,8 +203,6 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement, editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches, searchMatches: appState.searchMatches,
}); });
@@ -97,6 +97,7 @@ const getRelevantAppStateProps = (
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId, pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize, gridSize: appState.gridSize,
@@ -106,7 +107,6 @@ const getRelevantAppStateProps = (
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily, currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
}); });
const areEqual = ( const areEqual = (
@@ -36,7 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App"; import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks"; import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers"; import { getLinkHandleFromCoords } from "./helpers";
import { pointFrom, type GlobalPoint } from "../../../math"; import { point, type GlobalPoint } from "../../../math";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -181,7 +181,7 @@ export const Hyperlink = ({
element, element,
elementsMap, elementsMap,
appState, appState,
pointFrom(event.clientX, event.clientY), point(event.clientX, event.clientY),
) as boolean; ) as boolean;
if (shouldHide) { if (shouldHide) {
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
@@ -1,5 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math"; import type { GlobalPoint, Radians } from "../../../math";
import { pointFrom, pointRotateRads } from "../../../math"; import { point, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds"; import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds"; import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -35,8 +35,8 @@ export const getLinkHandleFromCoords = (
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(x + linkWidth / 2, y + linkHeight / 2), point(x + linkWidth / 2, y + linkHeight / 2),
pointFrom(centerX, centerY), point(centerX, centerY),
angle, angle,
); );
return [ return [
@@ -85,10 +85,5 @@ export const isPointHittingLink = (
) { ) {
return true; return true;
} }
return isPointHittingLinkIcon( return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
element,
elementsMap,
appState,
pointFrom(x, y),
);
}; };
-9
View File
@@ -2147,12 +2147,3 @@ export const upIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const cropIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
</g>,
tablerIconProps,
);
+8 -32
View File
@@ -1,8 +1,7 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types"; import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/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 isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
@@ -108,6 +107,7 @@ export const YOUTUBE_STATES = {
export const ENV = { export const ENV = {
TEST: "test", TEST: "test",
DEVELOPMENT: "development",
}; };
export const CLASSES = { export const CLASSES = {
@@ -116,9 +116,6 @@ export const CLASSES = {
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", 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. * // 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, "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 = { export const THEME = {
LIGHT: "light", LIGHT: "light",
DARK: "dark", DARK: "dark",
@@ -176,6 +157,8 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25, nameLineHeight: 1.25,
}; };
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; 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_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; 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; export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence // a small epsilon to make side resizing always take precedence
@@ -199,6 +174,8 @@ const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD = export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON; 2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS // keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6"; 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 EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_SMALLEST_EXPORT_SIZE = 20; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
@@ -390,8 +366,8 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: ExcalidrawElement["opacity"]; opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"]; locked: ExcalidrawElement["locked"];
} = { } = {
strokeColor: COLOR_CHARCOAL_BLACK, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_TRANSPARENT, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: 2, strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2", "backgroundColor": "#d8f5a2",
"boundElements": [ "boundElements": [
{ {
"id": "id47", "id": "id45",
"type": "arrow", "type": "arrow",
}, },
{ {
"id": "id48", "id": "id46",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id48", "id": "id46",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id49", "elementId": "id47",
"fixedPoint": null, "fixedPoint": null,
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id47", "id": "id45",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id50", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id50", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id51", "id": "id49",
"type": "text", "type": "text",
}, },
], ],
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id50", "containerId": "id48",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id40", "id": "id38",
"type": "text", "type": "text",
}, },
], ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id42", "elementId": "id40",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id41", "elementId": "id39",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id39", "containerId": "id37",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id39", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id39", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id44", "id": "id42",
"type": "text", "type": "text",
}, },
], ],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id46", "elementId": "id44",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id45", "elementId": "id43",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id43", "containerId": "id41",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id56", "id": "id54",
"type": "text", "type": "text",
}, },
{ {
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id57", "id": "id55",
"type": "text", "type": "text",
}, },
], ],
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id58", "id": "id56",
"type": "text", "type": "text",
}, },
{ {
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id59", "id": "id57",
"type": "text", "type": "text",
}, },
{ {
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id60", "id": "id58",
"type": "text", "type": "text",
}, },
], ],
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id61", "id": "id59",
"type": "text", "type": "text",
}, },
], ],
@@ -2171,7 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ANOTHER STYLED "text": "ANOTHER STYLED
LABELLED ARROW", LABELLED ARROW",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 140, "width": 150,
"x": 80, "x": 75,
"y": 275, "y": 275,
} }
`; `;
@@ -2213,7 +2213,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ANOTHER STYLED "text": "ANOTHER STYLED
LABELLED ARROW", LABELLED ARROW",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 140, "width": 150,
"x": 80, "x": 75,
"y": 375, "y": 375,
} }
`; `;
@@ -2518,7 +2518,7 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ELLIPSE TEXT "text": "ELLIPSE TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 130,
"x": 539.7893218813452, "x": 534.7893218813452,
"y": 117.44796179957173, "y": 117.44796179957173,
} }
`; `;
@@ -2562,7 +2562,7 @@ TEXT CONTAINER",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "DIAMOND "text": "DIAMOND
TEXT TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2646,8 +2646,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "TOP LEFT ALIGNED "text": "TOP LEFT ALIGNED
RECTANGLE TEXT RECTANGLE TEXT
CONTAINER", CONTAINER",
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 160, "width": 170,
"x": 505, "x": 505,
"y": 305, "y": 305,
} }
@@ -2689,8 +2689,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "STYLED "text": "STYLED
ELLIPSE TEXT ELLIPSE TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 130,
"x": 539.7893218813452, "x": 534.7893218813452,
"y": 522.5735931288071, "y": 522.5735931288071,
} }
`; `;
+4 -18
View File
@@ -8,14 +8,13 @@ import { calculateScrollCenter } from "../scene";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils"; import { bytesToHexString, isPromiseLike } from "../utils";
import { base64ToString, stringToBase64, toByteString } from "./encode";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "./filesystem";
import { nativeFileSystemSupported } from "./filesystem"; import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore"; import { restore, restoreLibraryItems } from "./restore";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
if (blob.type === MIME_TYPES.png) { if (blob.type === MIME_TYPES.png) {
@@ -47,7 +46,9 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
} }
if (blob.type === MIME_TYPES.svg) { if (blob.type === MIME_TYPES.svg) {
try { try {
return (await import("./image")).decodeSvgMetadata({ return await (
await import("./image")
).decodeSvgMetadata({
svg: contents, svg: contents,
}); });
} catch (error: any) { } 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> => { export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); 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 = "") => { export const dataURLToFile = (dataURL: DataURL, filename = "") => {
const dataIndexStart = dataURL.indexOf(","); const dataIndexStart = dataURL.indexOf(",");
const byteString = atob(dataURL.slice(dataIndexStart + 1)); const byteString = atob(dataURL.slice(dataIndexStart + 1));
@@ -284,10 +274,6 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
return new File([ab], filename, { type: mimeType }); return new File([ab], filename, { type: mimeType });
}; };
export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
export const resizeImageFile = async ( export const resizeImageFile = async (
file: File, file: File,
opts: { opts: {
+27 -40
View File
@@ -5,23 +5,24 @@ import { encryptData, decryptData } from "./encryption";
// byte (binary) strings // byte (binary) strings
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Buffer-compatible implem. // fast, Buffer-compatible implem
// export const toByteString = (
// Note that in V8, spreading the uint8array (by chunks) into data: string | Uint8Array | ArrayBuffer,
// `String.fromCharCode(...uint8array)` tends to be faster for large ): Promise<string> => {
// strings/buffers, in case perf is needed in the future. return new Promise((resolve, reject) => {
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => { const blob =
const bytes = typeof data === "string"
typeof data === "string" ? new Blob([new TextEncoder().encode(data)])
? new TextEncoder().encode(data) : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
: data instanceof Uint8Array const reader = new FileReader();
? data reader.onload = (event) => {
: new Uint8Array(data); if (!event.target || typeof event.target.result !== "string") {
let bstring = ""; return reject(new Error("couldn't convert to byte string"));
for (const byte of bytes) { }
bstring += String.fromCharCode(byte); resolve(event.target.result);
} };
return bstring; reader.readAsBinaryString(blob);
});
}; };
const byteStringToArrayBuffer = (byteString: string) => { const byteStringToArrayBuffer = (byteString: string) => {
@@ -45,12 +46,12 @@ const byteStringToString = (byteString: string) => {
* @param isByteString set to true if already byte string to prevent bloat * @param isByteString set to true if already byte string to prevent bloat
* due to reencoding * due to reencoding
*/ */
export const stringToBase64 = (str: string, isByteString = false) => { export const stringToBase64 = async (str: string, isByteString = false) => {
return isByteString ? window.btoa(str) : window.btoa(toByteString(str)); return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
}; };
// async to align with stringToBase64 // async to align with stringToBase64
export const base64ToString = (base64: string, isByteString = false) => { export const base64ToString = async (base64: string, isByteString = false) => {
return isByteString return isByteString
? window.atob(base64) ? window.atob(base64)
: byteStringToString(window.atob(base64)); : byteStringToString(window.atob(base64));
@@ -65,20 +66,6 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
return byteStringToArrayBuffer(atob(base64)); 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 // text encoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -95,18 +82,18 @@ type EncodedData = {
/** /**
* Encodes (and potentially compresses via zlib) text to byte string * Encodes (and potentially compresses via zlib) text to byte string
*/ */
export const encode = ({ export const encode = async ({
text, text,
compress, compress,
}: { }: {
text: string; text: string;
/** defaults to `true`. If compression fails, falls back to bstring alone. */ /** defaults to `true`. If compression fails, falls back to bstring alone. */
compress?: boolean; compress?: boolean;
}): EncodedData => { }): Promise<EncodedData> => {
let deflated!: string; let deflated!: string;
if (compress !== false) { if (compress !== false) {
try { try {
deflated = toByteString(deflate(text)); deflated = await toByteString(deflate(text));
} catch (error: any) { } catch (error: any) {
console.error("encode: cannot deflate", error); console.error("encode: cannot deflate", error);
} }
@@ -115,11 +102,11 @@ export const encode = ({
version: "1", version: "1",
encoding: "bstring", encoding: "bstring",
compressed: !!deflated, 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; let decoded: string;
switch (data.encoding) { switch (data.encoding) {
@@ -127,7 +114,7 @@ export const decode = (data: EncodedData): string => {
// if compressed, do not double decode the bstring // if compressed, do not double decode the bstring
decoded = data.compressed decoded = data.compressed
? data.encoded ? data.encoded
: byteStringToString(data.encoded); : await byteStringToString(data.encoded);
break; break;
default: default:
throw new Error(`decode: unknown encoding "${data.encoding}"`); throw new Error(`decode: unknown encoding "${data.encoding}"`);
+8 -8
View File
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
const metadataChunk = tEXt.encode( const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw, MIME_TYPES.excalidraw,
JSON.stringify( JSON.stringify(
encode({ await encode({
text: metadata, text: metadata,
compress: true, compress: true,
}), }),
@@ -59,7 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
} }
throw new Error("FAILED"); throw new Error("FAILED");
} }
return decode(encodedData); return await decode(encodedData);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
@@ -72,9 +72,9 @@ export const decodePngMetadata = async (blob: Blob) => {
// SVG // SVG
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const encodeSvgMetadata = ({ text }: { text: string }) => { export const encodeSvgMetadata = async ({ text }: { text: string }) => {
const base64 = stringToBase64( const base64 = await stringToBase64(
JSON.stringify(encode({ text })), JSON.stringify(await encode({ text })),
true /* is already byte string */, true /* is already byte string */,
); );
@@ -87,7 +87,7 @@ export const encodeSvgMetadata = ({ text }: { text: string }) => {
return metadata; return metadata;
}; };
export const decodeSvgMetadata = ({ svg }: { svg: string }) => { export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match( const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/, /<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
@@ -100,7 +100,7 @@ export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
const isByteString = version !== "1"; const isByteString = version !== "1";
try { try {
const json = base64ToString(match[1], isByteString); const json = await base64ToString(match[1], isByteString);
const encodedData = JSON.parse(json); const encodedData = JSON.parse(json);
if (!("encoded" in encodedData)) { if (!("encoded" in encodedData)) {
// legacy, un-encoded scene JSON // legacy, un-encoded scene JSON
@@ -112,7 +112,7 @@ export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
} }
throw new Error("FAILED"); throw new Error("FAILED");
} }
return decode(encodedData); return await decode(encodedData);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
throw new Error("FAILED"); throw new Error("FAILED");
+45 -63
View File
@@ -81,53 +81,46 @@ export const prepareElementsForExport = (
}; };
}; };
export const exportAsImage = async ({ export const exportCanvas = async (
type, type: Omit<ExportType, "backend">,
data, elements: ExportedElements,
config, appState: AppState,
}: { files: BinaryFiles,
type: Omit<ExportType, "backend">; {
data: { exportBackground,
elements: ExportedElements; exportPadding = DEFAULT_EXPORT_PADDING,
appState: AppState; viewBackgroundColor,
files: BinaryFiles; name = appState.name || DEFAULT_FILENAME,
}; fileHandle = null,
config: { exportingFrame = null,
}: {
exportBackground: boolean; exportBackground: boolean;
padding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
/** filename, if applicable */ /** filename, if applicable */
name?: string; name?: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}; },
}) => { ) => {
// clone if (elements.length === 0) {
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) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg({ const svgPromise = exportToSvg(
data: { elements,
elements: data.elements, {
appState: { exportBackground,
exportBackground: cfg.exportBackground, exportWithDarkMode: appState.exportWithDarkMode,
exportWithDarkMode: data.appState.exportWithDarkMode, viewBackgroundColor,
viewBackgroundColor: data.appState.viewBackgroundColor, exportPadding,
exportScale: data.appState.exportScale, exportScale: appState.exportScale,
exportEmbedScene: data.appState.exportEmbedScene && type === "svg", exportEmbedScene: appState.exportEmbedScene && type === "svg",
},
files: data.files,
}, },
config: { exportingFrame: cfg.exportingFrame, padding: cfg.padding }, files,
}); { exportingFrame },
);
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
svgPromise.then((svg) => { svgPromise.then((svg) => {
@@ -135,9 +128,9 @@ export const exportAsImage = async ({
}), }),
{ {
description: "Export to SVG", description: "Export to SVG",
name: cfg.name, name,
extension: data.appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle: cfg.fileHandle, fileHandle,
}, },
); );
} else if (type === "clipboard-svg") { } else if (type === "clipboard-svg") {
@@ -151,33 +144,22 @@ export const exportAsImage = async ({
} }
} }
const tempCanvas = exportToCanvas({ const tempCanvas = exportToCanvas(elements, appState, files, {
data, exportBackground,
config: { viewBackgroundColor,
canvasBackgroundColor: !cfg.exportBackground exportPadding,
? false exportingFrame,
: cfg.viewBackgroundColor,
padding: cfg.padding,
theme: data.appState.exportWithDarkMode ? "dark" : "light",
scale: data.appState.exportScale,
fit: "none",
exportingFrame: cfg.exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
const blob = canvasToBlob(tempCanvas); let blob = canvasToBlob(tempCanvas);
if (data.appState.exportEmbedScene) {
blob.then((blob) => if (appState.exportEmbedScene) {
blob = blob.then((blob) =>
import("./image").then(({ encodePngMetadata }) => import("./image").then(({ encodePngMetadata }) =>
encodePngMetadata({ encodePngMetadata({
blob, blob,
metadata: serializeAsJSON( metadata: serializeAsJSON(elements, appState, files, "local"),
data.elements,
data.appState,
data.files,
"local",
),
}), }),
), ),
); );
@@ -185,11 +167,11 @@ export const exportAsImage = async ({
return fileSave(blob, { return fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name: cfg.name, name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away // FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349 // from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png", extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle: cfg.fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
+7 -12
View File
@@ -1,7 +1,6 @@
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
import { prepareElementsForExport } from "."; import { exportCanvas, prepareElementsForExport } from ".";
import { exportAsImage } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob"; import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async ( export const resaveAsImageWithScene = async (
@@ -30,16 +29,12 @@ export const resaveAsImageWithScene = async (
false, false,
); );
await exportAsImage({ await exportCanvas(fileHandleType, exportedElements, appState, files, {
type: fileHandleType, exportBackground,
data: { elements: exportedElements, appState, files }, viewBackgroundColor,
config: { name,
exportBackground, fileHandle,
viewBackgroundColor, exportingFrame,
name,
fileHandle,
exportingFrame,
},
}); });
return { fileHandle }; return { fileHandle };
+3 -8
View File
@@ -57,7 +57,7 @@ import {
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, pointFrom } from "../../math"; import { isFiniteNumber, point } from "../../math";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -190,10 +190,6 @@ const restoreElementWithProperties = <
} }
return { return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
@@ -262,7 +258,6 @@ const restoreElement = (
status: element.status || "pending", status: element.status || "pending",
fileId: element.fileId, fileId: element.fileId,
scale: element.scale || [1, 1], scale: element.scale || [1, 1],
crop: element.crop ?? null,
}); });
case "line": case "line":
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
@@ -273,7 +268,7 @@ const restoreElement = (
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)] ? [point(0, 0), point(element.width, element.height)]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -301,7 +296,7 @@ const restoreElement = (
let y: number | undefined = element.y; let y: number | undefined = element.y;
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)] ? [point(0, 0), point(element.width, element.height)]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
+45 -50
View File
@@ -2,7 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform"; import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform"; import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types"; import type { ExcalidrawArrowElement } from "../element/types";
import { pointFrom } from "../../math"; import { point } from "../../math";
const opts = { regenerateIds: false }; const opts = { regenerateIds: false };
@@ -309,32 +309,28 @@ describe("Test Transform", () => {
}); });
describe("Test Frames", () => { describe("Test Frames", () => {
const elements: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
];
it("should transform frames and update frame ids when regenerated", () => { it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
...elements, {
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -356,9 +352,28 @@ describe("Test Transform", () => {
}); });
}); });
it("should consider user defined frame dimensions over calculated when provided", () => { it("should consider max of calculated and frame dimensions when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
...elements, {
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -373,27 +388,7 @@ describe("Test Transform", () => {
); );
const frame = excalidrawElements.find((ele) => ele.type === "frame")!; const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800); expect(frame.width).toBe(800);
expect(frame.height).toBe(100); expect(frame.height).toBe(126);
});
it("should consider user defined frame coordinates calculated when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
...elements,
{
type: "frame",
children: ["1", "2"],
name: "My frame",
x: 100,
y: 300,
},
];
const excalidrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.x).toBe(100);
expect(frame.y).toBe(300);
}); });
}); });
@@ -917,7 +912,7 @@ describe("Test Transform", () => {
x: 111.262, x: 111.262,
y: 57, y: 57,
strokeWidth: 2, strokeWidth: 2,
points: [pointFrom(0, 0), pointFrom(272.985, 0)], points: [point(0, 0), point(272.985, 0)],
label: { label: {
text: "How are you?", text: "How are you?",
fontSize: 20, fontSize: 20,
@@ -940,7 +935,7 @@ describe("Test Transform", () => {
x: 77.017, x: 77.017,
y: 79, y: 79,
strokeWidth: 2, strokeWidth: 2,
points: [pointFrom(0, 0)], points: [point(0, 0)],
label: { label: {
text: "Friendship", text: "Friendship",
fontSize: 20, fontSize: 20,
+8 -25
View File
@@ -46,7 +46,6 @@ import {
assertNever, assertNever,
cloneJSON, cloneJSON,
getFontString, getFontString,
isDevEnv,
toBrandedType, toBrandedType,
} from "../utils"; } from "../utils";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
@@ -54,7 +53,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks"; import { isArrowElement } from "../element/typeChecks";
import { pointFrom, type LocalPoint } from "../../math"; import { point, type LocalPoint } from "../../math";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -537,7 +536,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({ excalidrawElement = newLinearElement({
width, width,
height, height,
points: [pointFrom(0, 0), pointFrom(width, height)], points: [point(0, 0), point(width, height)],
...element, ...element,
}); });
@@ -550,7 +549,7 @@ export const convertToExcalidrawElements = (
width, width,
height, height,
endArrowhead: "arrow", endArrowhead: "arrow",
points: [pointFrom(0, 0), pointFrom(width, height)], points: [point(0, 0), point(width, height)],
...element, ...element,
type: "arrow", type: "arrow",
}); });
@@ -718,7 +717,7 @@ export const convertToExcalidrawElements = (
} }
// Once all the excalidraw elements are created, we can add frames since we // Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possible after all // need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed. // frame children are processed.
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") { if (element.type !== "frame" && element.type !== "magicframe") {
@@ -765,26 +764,10 @@ export const convertToExcalidrawElements = (
maxX = maxX + PADDING; maxX = maxX + PADDING;
maxY = maxY + PADDING; maxY = maxY + PADDING;
const frameX = frame?.x || minX; // Take the max of calculated and provided frame dimensions, whichever is higher
const frameY = frame?.y || minY; const width = Math.max(frame?.width, maxX - minX);
const frameWidth = frame?.width || maxX - minX; const height = Math.max(frame?.height, maxY - minY);
const frameHeight = frame?.height || maxY - minY; Object.assign(frame, { x: minX, y: minY, width, height });
Object.assign(frame, {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight,
});
if (
isDevEnv() &&
element.children.length &&
(frame?.x || frame?.y || frame?.width || frame?.height)
) {
console.info(
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
);
}
} }
return elementStore.getElements(); return elementStore.getElements();
+37 -46
View File
@@ -66,7 +66,7 @@ import {
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { import {
lineSegment, lineSegment,
pointFrom, point,
pointRotateRads, pointRotateRads,
type GlobalPoint, type GlobalPoint,
vectorFromPoint, vectorFromPoint,
@@ -576,11 +576,11 @@ export const updateBoundElements = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; oldSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {}; const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@@ -603,12 +603,12 @@ export const updateBoundElements = (
startBinding: maybeCalculateNewGapWhenScaling( startBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.startBinding, element.startBinding,
newSize, oldSize,
), ),
endBinding: maybeCalculateNewGapWhenScaling( endBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.endBinding, element.endBinding,
newSize, oldSize,
), ),
}; };
@@ -720,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
return vectorToHeading( return vectorToHeading(
vectorFromPoint( vectorFromPoint(
p, p,
pointFrom<GlobalPoint>( point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2, bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2, bindableElement.y + bindableElement.height / 2,
), ),
@@ -766,15 +766,15 @@ export const bindPointToSnapToElementOutline = (
const intersections = [ const intersections = [
...(intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
pointFrom(p[0], p[1] - 2 * bindableElement.height), point(p[0], p[1] - 2 * bindableElement.height),
pointFrom(p[0], p[1] + 2 * bindableElement.height), point(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
) ?? []), ) ?? []),
...(intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
pointFrom(p[0] - 2 * bindableElement.width, p[1]), point(p[0] - 2 * bindableElement.width, p[1]),
pointFrom(p[0] + 2 * bindableElement.width, p[1]), point(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
) ?? []), ) ?? []),
@@ -815,25 +815,25 @@ const headingToMidBindPoint = (
switch (true) { switch (true) {
case compareHeading(heading, HEADING_UP): case compareHeading(heading, HEADING_UP):
return pointRotateRads( return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]), point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_RIGHT): case compareHeading(heading, HEADING_RIGHT):
return pointRotateRads( return pointRotateRads(
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1), point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_DOWN): case compareHeading(heading, HEADING_DOWN):
return pointRotateRads( return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]), point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
default: default:
return pointRotateRads( return pointRotateRads(
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1), point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
@@ -844,7 +844,7 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = pointFrom<GlobalPoint>( const center = point<GlobalPoint>(
element.x + element.width / 2, element.x + element.width / 2,
element.y + element.height / 2, element.y + element.height / 2,
); );
@@ -854,13 +854,13 @@ export const avoidRectangularCorner = (
// Top left // Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return pointRotateRads<GlobalPoint>( return pointRotateRads<GlobalPoint>(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), point(element.x - FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE), point(element.x, element.y - FIXED_BINDING_DISTANCE),
center, center,
element.angle, element.angle,
); );
@@ -871,16 +871,13 @@ export const avoidRectangularCorner = (
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return pointRotateRads( return pointRotateRads(
pointFrom( point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
element.x,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center, center,
element.angle, element.angle,
); );
@@ -894,7 +891,7 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( point(
element.x + element.width, element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE, element.y + element.height + FIXED_BINDING_DISTANCE,
), ),
@@ -903,7 +900,7 @@ export const avoidRectangularCorner = (
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom( point(
element.x + element.width + FIXED_BINDING_DISTANCE, element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height, element.y + element.height,
), ),
@@ -920,16 +917,13 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
element.x + element.width,
element.y - FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
@@ -944,10 +938,7 @@ export const snapToMid = (
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = pointFrom<GlobalPoint>( const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
x + width / 2 - 0.1,
y + height / 2 - 0.1,
);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
@@ -962,7 +953,7 @@ export const snapToMid = (
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), point(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@@ -973,7 +964,7 @@ export const snapToMid = (
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y - FIXED_BINDING_DISTANCE), point(center[0], y - FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@@ -984,7 +975,7 @@ export const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]), point(x + width + FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@@ -995,7 +986,7 @@ export const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE), point(center[0], y + height + FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@@ -1032,11 +1023,11 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap, elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = pointFrom<GlobalPoint>( const globalMidPoint = point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2, bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2, bindableElement.y + bindableElement.height / 2,
); );
const global = pointFrom<GlobalPoint>( const global = point<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
); );
@@ -1127,7 +1118,7 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement, hoveredElement,
elementsMap, elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = point(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
); );
@@ -1346,9 +1337,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return ( return (
isPointOnShape(pointFrom(x, y), shape, threshold) || isPointOnShape(point(x, y), shape, threshold) ||
(fullShape === true && (fullShape === true &&
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) pointInsideBounds(point(x, y), aabbForElement(element)))
); );
}; };
@@ -2206,11 +2197,11 @@ export const getGlobalFixedPointForBindableElement = (
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return pointRotateRads( return pointRotateRads(
pointFrom( point(
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
pointFrom<GlobalPoint>( point<GlobalPoint>(
element.x + element.width / 2, element.x + element.width / 2,
element.y + element.height / 2, element.y + element.height / 2,
), ),
@@ -2238,7 +2229,7 @@ const getGlobalFixedPoints = (
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
) )
: pointFrom<GlobalPoint>( : point<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1], arrow.y + arrow.points[0][1],
); );
@@ -2248,7 +2239,7 @@ const getGlobalFixedPoints = (
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
) )
: pointFrom<GlobalPoint>( : point<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1], arrow.y + arrow.points[arrow.points.length - 1][1],
); );
+4 -4
View File
@@ -1,5 +1,5 @@
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { point } from "../../math";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -125,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416, a: 0.6447741904932416,
}), }),
points: [ points: [
pointFrom<LocalPoint>(0, 0), point<LocalPoint>(0, 0),
pointFrom<LocalPoint>(67.33984375, 92.48828125), point<LocalPoint>(67.33984375, 92.48828125),
pointFrom<LocalPoint>(-102.7890625, 52.15625), point<LocalPoint>(-102.7890625, 52.15625),
], ],
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
+46 -46
View File
@@ -34,7 +34,7 @@ import type {
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
pointFrom, point,
pointDistance, pointDistance,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
@@ -113,8 +113,8 @@ export class ElementBounds {
const [minX, minY, maxX, maxY] = getBoundsFromPoints( const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) => element.points.map(([x, y]) =>
pointRotateRads( pointRotateRads(
pointFrom(x, y), point(x, y),
pointFrom(cx - element.x, cy - element.y), point(cx - element.x, cy - element.y),
element.angle, element.angle,
), ),
), ),
@@ -130,23 +130,23 @@ export class ElementBounds {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(cx, y1), point(cx, y1),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x12, y12] = pointRotateRads( const [x12, y12] = pointRotateRads(
pointFrom(cx, y2), point(cx, y2),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x22, y22] = pointRotateRads( const [x22, y22] = pointRotateRads(
pointFrom(x1, cy), point(x1, cy),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x21, y21] = pointRotateRads( const [x21, y21] = pointRotateRads(
pointFrom(x2, cy), point(x2, cy),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
@@ -164,23 +164,23 @@ export class ElementBounds {
bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else { } else {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
pointFrom(x1, y1), point(x1, y1),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x12, y12] = pointRotateRads( const [x12, y12] = pointRotateRads(
pointFrom(x1, y2), point(x1, y2),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x22, y22] = pointRotateRads( const [x22, y22] = pointRotateRads(
pointFrom(x2, y2), point(x2, y2),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const [x21, y21] = pointRotateRads( const [x21, y21] = pointRotateRads(
pointFrom(x2, y1), point(x2, y1),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
@@ -255,7 +255,7 @@ export const getElementLineSegments = (
elementsMap, elementsMap,
); );
const center: GlobalPoint = pointFrom(cx, cy); const center: GlobalPoint = point(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
@@ -266,7 +266,7 @@ export const getElementLineSegments = (
segments.push( segments.push(
lineSegment( lineSegment(
pointRotateRads( pointRotateRads(
pointFrom( point(
element.points[i][0] + element.x, element.points[i][0] + element.x,
element.points[i][1] + element.y, element.points[i][1] + element.y,
), ),
@@ -274,7 +274,7 @@ export const getElementLineSegments = (
element.angle, element.angle,
), ),
pointRotateRads( pointRotateRads(
pointFrom( point(
element.points[i + 1][0] + element.x, element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y, element.points[i + 1][1] + element.y,
), ),
@@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (p: GlobalPoint) => GlobalPoint, transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => { ): Bounds => {
let currentP: GlobalPoint = pointFrom(0, 0); let currentP: GlobalPoint = point(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
@@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
const _p1 = pointFrom<GlobalPoint>(data[0], data[1]); const _p1 = point<GlobalPoint>(data[0], data[1]);
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]); const _p2 = point<GlobalPoint>(data[2], data[3]);
const _p3 = pointFrom<GlobalPoint>(data[4], data[5]); const _p3 = point<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(_p1) : _p1; const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2; const p2 = transformXY ? transformXY(_p2) : _p2;
@@ -591,21 +591,21 @@ export const getArrowheadPoints = (
invariant(data.length === 6, "Op data length is not 6"); invariant(data.length === 6, "Op data length is not 6");
const p3 = pointFrom(data[4], data[5]); const p3 = point(data[4], data[5]);
const p2 = pointFrom(data[2], data[3]); const p2 = point(data[2], data[3]);
const p1 = pointFrom(data[0], data[1]); const p1 = point(data[0], data[1]);
// We need to find p0 of the bezier curve. // We need to find p0 of the bezier curve.
// It is typically the last point of the previous // It is typically the last point of the previous
// curve; it can also be the position of moveTo operation. // curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1]; const prevOp = ops[index - 1];
let p0 = pointFrom(0, 0); let p0 = point(0, 0);
if (prevOp.op === "move") { if (prevOp.op === "move") {
const p = pointFromArray(prevOp.data); const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point"); invariant(p != null, "Op data is not a point");
p0 = p; p0 = p;
} else if (prevOp.op === "bcurveTo") { } else if (prevOp.op === "bcurveTo") {
p0 = pointFrom(prevOp.data[4], prevOp.data[5]); p0 = point(prevOp.data[4], prevOp.data[5]);
} }
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -671,13 +671,13 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), point(xs, ys),
pointFrom(x2, y2), point(x2, y2),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), point(xs, ys),
pointFrom(x2, y2), point(x2, y2),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -690,8 +690,8 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2), point(x2 + minSize * 2, y2),
pointFrom(x2, y2), point(x2, y2),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
} else { } else {
@@ -701,8 +701,8 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), point(x2 - minSize * 2, y2),
pointFrom(x2, y2), point(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
} }
@@ -746,8 +746,8 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) { if (element.points.length < 2) {
const [pointX, pointY] = element.points[0]; const [pointX, pointY] = element.points[0];
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom(element.x + pointX, element.y + pointY), point(element.x + pointX, element.y + pointY),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
@@ -775,8 +775,8 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) => const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>( pointRotateRads<GlobalPoint>(
pointFrom(element.x + x, element.y + y), point(element.x + x, element.y + y),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
@@ -931,8 +931,8 @@ export const getClosestElementBounds = (
elements.forEach((element) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = pointDistance( const distance = pointDistance(
pointFrom((x1 + x2) / 2, (y1 + y2) / 2), point((x1 + x2) / 2, (y1 + y2) / 2),
pointFrom(from.x, from.y), point(from.x, from.y),
); );
if (distance < minDistance) { if (distance < minDistance) {
@@ -990,7 +990,7 @@ export const getVisibleSceneBounds = ({
}; };
export const getCenterForBounds = (bounds: Bounds): GlobalPoint => export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
pointFrom( point(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
); );
+7 -11
View File
@@ -17,7 +17,7 @@ import {
} from "./typeChecks"; } from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes"; import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, pointFrom } from "../../math"; import { isPointWithinBounds, point } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
@@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
let hit = shouldTestInside(element) let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) || isPointInShape(point(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold) isPointOnShape(point(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold); : isPointOnShape(point(x, y), shape, threshold);
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
hit = isPointInShape(pointFrom(x, y), { hit = isPointInShape(point(x, y), {
type: "polygon", type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>, .data as Polygon<Point>,
@@ -89,11 +89,7 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = <
@@ -119,5 +115,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
y: number, y: number,
textShape: GeometricShape<Point> | null, textShape: GeometricShape<Point> | null,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape); return !!textShape && isPointInShape(point(x, y), textShape);
}; };
-587
View File
@@ -1,587 +0,0 @@
import { type Point } from "points-on-curve";
import {
type Radians,
pointFrom,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
isCloseTo,
} from "../../math";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
pointerX: number,
pointerY: number,
widthAspectRatio?: number,
) => {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
/**
* uncropped width
* **
* | (x,y) (natural) |
* | ** |
* | |///////| height | uncropped height
* | ** |
* | width (natural) |
* **
*/
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
-element.angle as Radians,
);
pointerX = rotatedPointer[0];
pointerY = rotatedPointer[1];
let nextWidth = element.width;
let nextHeight = element.height;
let crop: ImageCrop | null = element.crop ?? {
x: 0,
y: 0,
width: naturalWidth,
height: naturalHeight,
naturalWidth,
naturalHeight,
};
const previousCropHeight = crop.height;
const previousCropWidth = crop.width;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let changeInHeight = pointerY - element.y;
let changeInWidth = pointerX - element.x;
if (transformHandle.includes("n")) {
nextHeight = clamp(
element.height - changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
);
}
if (transformHandle.includes("s")) {
changeInHeight = pointerY - element.y - element.height;
nextHeight = clamp(
element.height + changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
);
}
if (transformHandle.includes("e")) {
changeInWidth = pointerX - element.x - element.width;
nextWidth = clamp(
element.width + changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
);
}
if (transformHandle.includes("w")) {
nextWidth = clamp(
element.width - changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
);
}
const updateCropWidthAndHeight = (crop: ImageCrop) => {
crop.height = nextHeight * naturalHeightToUncropped;
crop.width = nextWidth * naturalWidthToUncropped;
};
updateCropWidthAndHeight(crop);
const adjustFlipForHandle = (
handle: TransformHandleType,
crop: ImageCrop,
) => {
updateCropWidthAndHeight(crop);
if (handle.includes("n")) {
if (!isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("s")) {
if (isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("e")) {
if (isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
if (handle.includes("w")) {
if (!isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
};
switch (transformHandle) {
case "n": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "s": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "w": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "e": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "ne": {
if (widthAspectRatio) {
if (changeInWidth > -changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "nw": {
if (widthAspectRatio) {
if (changeInWidth < changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "se": {
if (widthAspectRatio) {
if (changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "sw": {
if (widthAspectRatio) {
if (-changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
default:
break;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
!!widthAspectRatio,
);
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
transformHandle: TransformHandleType,
width: number,
height: number,
shouldMaintainAspectRatio?: boolean,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandle)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandle === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandle === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandle)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandle)) {
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// adjust topLeft to new rotation point
const angle = stateAtCropStart.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
return newOrigin;
};
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
export const getUncroppedImageElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (element.crop) {
const { width, height } = getUncroppedWidthAndHeight(element);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-cropX * width) / element.crop.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-cropY * height) / element.crop.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-element.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...element,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return element;
};
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) {
const width =
element.width / (element.crop.width / element.crop.naturalWidth);
const height =
element.height / (element.crop.height / element.crop.naturalHeight);
return {
width,
height,
};
}
return {
width: element.width,
height: element.height,
};
};
const adjustCropPosition = (
crop: ImageCrop,
scale: ExcalidrawImageElement["scale"],
) => {
let cropX = crop.x;
let cropY = crop.y;
const flipX = scale[0] === -1;
const flipY = scale[1] === -1;
if (flipX) {
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
}
if (flipY) {
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
}
return {
cropX,
cropY,
};
};
@@ -16,7 +16,6 @@ import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
@@ -252,14 +251,6 @@ export const dragNewElement = ({
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
mutateElement( mutateElement(
newElement, newElement,
{ {
@@ -268,7 +259,6 @@ export const dragNewElement = ({
width, width,
height, height,
...textAutoResize, ...textAutoResize,
...imageInitialDimension,
}, },
informMutation, informMutation,
); );
+1 -32
View File
@@ -4,7 +4,7 @@ import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils"; import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textElement";
import { isIframeElement } from "./typeChecks"; import { isIframeElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -45,12 +45,6 @@ const RE_GENERIC_EMBED =
const RE_GIPHY = const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/; /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const RE_REDDIT =
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -65,7 +59,6 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com", "giphy.com",
"reddit.com",
]); ]);
const ALLOW_SAME_ORIGIN = new Set([ const ALLOW_SAME_ORIGIN = new Set([
@@ -78,7 +71,6 @@ const ALLOW_SAME_ORIGIN = new Set([
"x.com", "x.com",
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"reddit.com",
]); ]);
export const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
@@ -226,24 +218,6 @@ export const getEmbedLink = (
return ret; return ret;
} }
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = sanitizeHTMLAttribute(
@@ -387,11 +361,6 @@ export const maybeParseEmbedSrc = (str: string): string => {
return twitterMatch[1]; return twitterMatch[1];
} }
const redditMatch = str.match(RE_REDDIT_EMBED);
if (redditMatch && redditMatch.length === 2) {
return redditMatch[1];
}
const gistMatch = str.match(RE_GH_GIST_EMBED); const gistMatch = str.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) { if (gistMatch && gistMatch.length === 2) {
return gistMatch[1]; return gistMatch[1];
+2 -2
View File
@@ -29,7 +29,7 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
} from "./typeChecks"; } from "./typeChecks";
import { invariant } from "../utils"; import { invariant } from "../utils";
import { pointFrom, type LocalPoint } from "../../math"; import { point, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes"; import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@@ -421,7 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor, strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: appState.currentItemStrokeWidth,
points: [pointFrom(0, 0), pointFrom(endX, endY)], points: [point(0, 0), point(endX, endY)],
elbowed: true, elbowed: true,
}); });
+9 -9
View File
@@ -6,7 +6,7 @@ import type {
Radians, Radians,
} from "../../math"; } from "../../math";
import { import {
pointFrom, point,
pointRotateRads, pointRotateRads,
pointScaleFromOrigin, pointScaleFromOrigin,
radiansToDegrees, radiansToDegrees,
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
const top = pointRotateRads( const top = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y), point(element.x + element.width / 2, element.y),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -91,7 +91,7 @@ export const headingForPointFromElement = <
); );
const right = pointRotateRads( const right = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
pointFrom(element.x + element.width, element.y + element.height / 2), point(element.x + element.width, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -100,7 +100,7 @@ export const headingForPointFromElement = <
); );
const bottom = pointRotateRads( const bottom = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y + element.height), point(element.x + element.width / 2, element.y + element.height),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -109,7 +109,7 @@ export const headingForPointFromElement = <
); );
const left = pointRotateRads( const left = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
pointFrom(element.x, element.y + element.height / 2), point(element.x, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -133,22 +133,22 @@ export const headingForPointFromElement = <
} }
const topLeft = pointScaleFromOrigin( const topLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[1]), point(aabb[0], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const topRight = pointScaleFromOrigin( const topRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[1]), point(aabb[2], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const bottomLeft = pointScaleFromOrigin( const bottomLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[3]), point(aabb[0], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const bottomRight = pointScaleFromOrigin( const bottomRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[3]), point(aabb[2], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
+1 -1
View File
@@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg"; 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 doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg"); const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror"); const errorNode = doc.querySelector("parsererror");
@@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
pointCenter, pointCenter,
pointFrom, point,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vector, vector,
@@ -108,7 +108,7 @@ export class LinearElementEditor {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
if (!pointsEqual(element.points[0], pointFrom(0, 0))) { if (!pointsEqual(element.points[0], point(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
} }
@@ -287,7 +287,7 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
referencePoint, referencePoint,
pointFrom(scenePointerX, scenePointerY), point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
@@ -296,7 +296,7 @@ export class LinearElementEditor {
[ [
{ {
index: selectedIndex, index: selectedIndex,
point: pointFrom( point: point(
width + referencePoint[0], width + referencePoint[0],
height + referencePoint[1], height + referencePoint[1],
), ),
@@ -329,7 +329,7 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
) )
: pointFrom( : point(
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY, element.points[pointIndex][1] + deltaY,
); );
@@ -590,11 +590,11 @@ export class LinearElementEditor {
linearElementEditor.segmentMidPointHoveredCoords; linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) { if (existingSegmentMidpointHitCoords) {
const distance = pointDistance( const distance = pointDistance(
pointFrom( point(
existingSegmentMidpointHitCoords[0], existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1], existingSegmentMidpointHitCoords[1],
), ),
pointFrom(scenePointer.x, scenePointer.y), point(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
return existingSegmentMidpointHitCoords; return existingSegmentMidpointHitCoords;
@@ -606,8 +606,8 @@ export class LinearElementEditor {
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
const distance = pointDistance( const distance = pointDistance(
pointFrom(midPoints[index]![0], midPoints[index]![1]), point(midPoints[index]![0], midPoints[index]![1]),
pointFrom(scenePointer.x, scenePointer.y), point(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
return midPoints[index]; return midPoints[index];
@@ -626,8 +626,8 @@ export class LinearElementEditor {
zoom: AppState["zoom"], zoom: AppState["zoom"],
) { ) {
let distance = pointDistance( let distance = pointDistance(
pointFrom(startPoint[0], startPoint[1]), point(startPoint[0], startPoint[1]),
pointFrom(endPoint[0], endPoint[1]), point(endPoint[0], endPoint[1]),
); );
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
@@ -829,11 +829,11 @@ export class LinearElementEditor {
const targetPoint = const targetPoint =
clickedPointIndex > -1 && clickedPointIndex > -1 &&
pointRotateRads( pointRotateRads(
pointFrom( point(
element.x + element.points[clickedPointIndex][0], element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1], element.y + element.points[clickedPointIndex][1],
), ),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
@@ -928,11 +928,11 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
pointFrom(scenePointerX, scenePointerY), point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
newPoint = pointFrom( newPoint = point(
width + lastCommittedPoint[0], width + lastCommittedPoint[0],
height + lastCommittedPoint[1], height + lastCommittedPoint[1],
); );
@@ -984,8 +984,8 @@ export class LinearElementEditor {
const { x, y } = element; const { x, y } = element;
return pointRotateRads( return pointRotateRads(
pointFrom(x + p[0], y + p[1]), point(x + p[0], y + p[1]),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
} }
@@ -1001,8 +1001,8 @@ export class LinearElementEditor {
return element.points.map((p) => { return element.points.map((p) => {
const { x, y } = element; const { x, y } = element;
return pointRotateRads( return pointRotateRads(
pointFrom(x + p[0], y + p[1]), point(x + p[0], y + p[1]),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
}); });
@@ -1025,12 +1025,8 @@ export class LinearElementEditor {
const { x, y } = element; const { x, y } = element;
return p return p
? pointRotateRads( ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
pointFrom(x + p[0], y + p[1]), : pointRotateRads(point(x, y), point(cx, cy), element.angle);
pointFrom(cx, cy),
element.angle,
)
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
} }
static pointFromAbsoluteCoords( static pointFromAbsoluteCoords(
@@ -1040,7 +1036,7 @@ export class LinearElementEditor {
): LocalPoint { ): LocalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// No rotation for elbow arrows // No rotation for elbow arrows
return pointFrom( return point(
absoluteCoords[0] - element.x, absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y, absoluteCoords[1] - element.y,
); );
@@ -1050,11 +1046,11 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom(absoluteCoords[0], absoluteCoords[1]), point(absoluteCoords[0], absoluteCoords[1]),
pointFrom(cx, cy), point(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
return pointFrom(x - element.x, y - element.y); return point(x - element.x, y - element.y);
} }
static getPointIndexUnderCursor( static getPointIndexUnderCursor(
@@ -1075,7 +1071,7 @@ export class LinearElementEditor {
while (--idx > -1) { while (--idx > -1) {
const p = pointHandles[idx]; const p = pointHandles[idx];
if ( if (
pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value < pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1 LinearElementEditor.POINT_HANDLE_SIZE + 1
) { ) {
@@ -1097,12 +1093,12 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerOnGrid[0], pointerOnGrid[1]), point(pointerOnGrid[0], pointerOnGrid[1]),
pointFrom(cx, cy), point(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
return pointFrom(rotatedX - element.x, rotatedY - element.y); return point(rotatedX - element.x, rotatedY - element.y);
} }
/** /**
@@ -1122,7 +1118,7 @@ export class LinearElementEditor {
return { return {
points: points.map((p) => { points: points.map((p) => {
return pointFrom(p[0] - offsetX, p[1] - offsetY); return point(p[0] - offsetX, p[1] - offsetY);
}), }),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
@@ -1176,8 +1172,8 @@ export class LinearElementEditor {
} }
acc.push( acc.push(
nextPoint nextPoint
? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: pointFrom(p[0], p[1]), : point(p[0], p[1]),
); );
nextSelectedIndices.push(indexCursor + 1); nextSelectedIndices.push(indexCursor + 1);
@@ -1198,7 +1194,7 @@ export class LinearElementEditor {
[ [
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), point: point(lastPoint[0] + 30, lastPoint[1] + 30),
}, },
], ],
elementsMap, elementsMap,
@@ -1239,9 +1235,7 @@ export class LinearElementEditor {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) { if (!pointIndices.includes(idx)) {
acc.push( acc.push(
!acc.length !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
); );
} }
return acc; return acc;
@@ -1318,9 +1312,9 @@ export class LinearElementEditor {
const deltaY = const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1]; selectedPointData.point[1] - points[selectedPointData.index][1];
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
} }
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
}); });
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
@@ -1374,8 +1368,8 @@ export class LinearElementEditor {
const origin = linearElementEditor.pointerDownState.origin!; const origin = linearElementEditor.pointerDownState.origin!;
const dist = pointDistance( const dist = pointDistance(
pointFrom(origin.x, origin.y), point(origin.x, origin.y),
pointFrom(pointerCoords.x, pointerCoords.y), point(pointerCoords.x, pointerCoords.y),
); );
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
@@ -1499,8 +1493,8 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads( const rotated = pointRotateRads(
pointFrom(offsetX, offsetY), point(offsetX, offsetY),
pointFrom(dX, dY), point(dX, dY),
element.angle, element.angle,
); );
mutateElement(element, { mutateElement(element, {
@@ -1546,8 +1540,8 @@ export class LinearElementEditor {
); );
return pointRotateRads( return pointRotateRads(
pointFrom(width, height), point(width, height),
pointFrom(0, 0), point(0, 0),
-element.angle as Radians, -element.angle as Radians,
); );
} }
@@ -1617,36 +1611,36 @@ export class LinearElementEditor {
); );
const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height; const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = pointFrom(cx, cy); const centerPoint = point(cx, cy);
const topLeftRotatedPoint = pointRotateRads( const topLeftRotatedPoint = pointRotateRads(
pointFrom(x1, y1), point(x1, y1),
centerPoint, centerPoint,
element.angle, element.angle,
); );
const topRightRotatedPoint = pointRotateRads( const topRightRotatedPoint = pointRotateRads(
pointFrom(x2, y1), point(x2, y1),
centerPoint, centerPoint,
element.angle, element.angle,
); );
const counterRotateBoundTextTopLeft = pointRotateRads( const counterRotateBoundTextTopLeft = pointRotateRads(
pointFrom(boundTextX1, boundTextY1), point(boundTextX1, boundTextY1),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextTopRight = pointRotateRads( const counterRotateBoundTextTopRight = pointRotateRads(
pointFrom(boundTextX2, boundTextY1), point(boundTextX2, boundTextY1),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextBottomLeft = pointRotateRads( const counterRotateBoundTextBottomLeft = pointRotateRads(
pointFrom(boundTextX1, boundTextY2), point(boundTextX1, boundTextY2),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextBottomRight = pointRotateRads( const counterRotateBoundTextBottomRight = pointRotateRads(
pointFrom(boundTextX2, boundTextY2), point(boundTextX2, boundTextY2),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
@@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types"; import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { point } from "../../math";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
mutateElement(element, { mutateElement(element, {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)], points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
}); });
const copy = duplicateElement(null, new Map(), element); const copy = duplicateElement(null, new Map(), element);
+1 -3
View File
@@ -34,9 +34,9 @@ import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
measureText, measureText,
normalizeText, normalizeText,
wrapText,
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -477,7 +477,6 @@ export const newImageElement = (
status?: ExcalidrawImageElement["status"]; status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"]; fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
crop?: ExcalidrawImageElement["crop"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
return { return {
@@ -488,7 +487,6 @@ export const newImageElement = (
status: opts.status ?? "pending", status: opts.status ?? "pending",
fileId: opts.fileId ?? null, fileId: opts.fileId ?? null,
scale: opts.scale ?? [1, 1], scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
}; };
}; };
+49 -60
View File
@@ -47,10 +47,10 @@ import {
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
wrapText,
measureText, measureText,
getMinTextElementWidth, getMinTextElementWidth,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing"; import { mutateElbowArrow } from "./routing";
@@ -58,7 +58,7 @@ import type { GlobalPoint } from "../../math";
import { import {
pointCenter, pointCenter,
normalizeRadians, normalizeRadians,
pointFrom, point,
pointFromPair, pointFromPair,
pointRotateRads, pointRotateRads,
type Radians, type Radians,
@@ -240,8 +240,8 @@ const resizeSingleTextElement = (
); );
// rotation pointer with reverse angle // rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerX, pointerY), point(pointerX, pointerY),
pointFrom(cx, cy), point(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
let scaleX = 0; let scaleX = 0;
@@ -276,23 +276,23 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2]; const startBottomRight = [x2, y2];
const startCenter = [cx, cy]; const startCenter = [cx, cy];
let newTopLeft = pointFrom<GlobalPoint>(x1, y1); let newTopLeft = point<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) { if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = pointFrom<GlobalPoint>( newTopLeft = point<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth), startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight), startBottomRight[1] - Math.abs(nextHeight),
); );
} }
if (transformHandleType === "ne") { if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]]; const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = pointFrom<GlobalPoint>( newTopLeft = point<GlobalPoint>(
bottomLeft[0], bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight), bottomLeft[1] - Math.abs(nextHeight),
); );
} }
if (transformHandleType === "sw") { if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]]; const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = pointFrom<GlobalPoint>( newTopLeft = point<GlobalPoint>(
topRight[0] - Math.abs(nextWidth), topRight[0] - Math.abs(nextWidth),
topRight[1], topRight[1],
); );
@@ -311,20 +311,12 @@ const resizeSingleTextElement = (
} }
const angle = element.angle; const angle = element.angle;
const rotatedTopLeft = pointRotateRads( const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
newTopLeft, const newCenter = point<GlobalPoint>(
pointFrom(cx, cy),
angle,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2, newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2, newTopLeft[1] + Math.abs(nextHeight) / 2,
); );
const rotatedNewCenter = pointRotateRads( const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads( newTopLeft = pointRotateRads(
rotatedTopLeft, rotatedTopLeft,
rotatedNewCenter, rotatedNewCenter,
@@ -349,12 +341,12 @@ const resizeSingleTextElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft = pointFrom<GlobalPoint>(x1, y1); const startTopLeft = point<GlobalPoint>(x1, y1);
const startBottomRight = pointFrom<GlobalPoint>(x2, y2); const startBottomRight = point<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), point(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle as Radians, -stateAtResizeStart.angle as Radians,
); );
@@ -427,7 +419,7 @@ const resizeSingleTextElement = (
startCenter, startCenter,
angle, angle,
); );
const newCenter = pointFrom( const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
); );
@@ -469,13 +461,13 @@ export const resizeSingleElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft = pointFrom(x1, y1); const startTopLeft = point(x1, y1);
const startBottomRight = pointFrom(x2, y2); const startBottomRight = point(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position // Calculate new dimensions based on cursor position
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), point(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle as Radians, -stateAtResizeStart.angle as Radians,
); );
@@ -656,7 +648,7 @@ export const resizeSingleElement = (
startCenter, startCenter,
angle, angle,
); );
const newCenter = pointFrom( const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
); );
@@ -739,9 +731,9 @@ export const resizeSingleElement = (
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
newSize: { oldSize: {
width: resizedElement.width, width: stateAtResizeStart.width,
height: resizedElement.height, height: stateAtResizeStart.height,
}, },
}); });
@@ -825,20 +817,20 @@ export const resizeMultipleElements = (
const direction = transformHandleType; const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = { const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: pointFrom(minX, maxY), ne: point(minX, maxY),
se: pointFrom(minX, minY), se: point(minX, minY),
sw: pointFrom(maxX, minY), sw: point(maxX, minY),
nw: pointFrom(maxX, maxY), nw: point(maxX, maxY),
e: pointFrom(minX, minY + height / 2), e: point(minX, minY + height / 2),
w: pointFrom(maxX, minY + height / 2), w: point(maxX, minY + height / 2),
n: pointFrom(minX + width / 2, maxY), n: point(minX + width / 2, maxY),
s: pointFrom(minX + width / 2, minY), s: point(minX + width / 2, minY),
}; };
// anchor point must be on the opposite side of the dragged selection handle // anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter // or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY] = shouldResizeFromCenter const [anchorX, anchorY] = shouldResizeFromCenter
? pointFrom(midX, midY) ? point(midX, midY)
: anchorsMap[direction]; : anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -999,13 +991,14 @@ export const resizeMultipleElements = (
element, element,
update: { boundTextFontSize, ...update }, update: { boundTextFontSize, ...update },
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { angle, width: newWidth, height: newHeight } = update; const { angle } = update;
const { width: oldWidth, height: oldHeight } = element;
mutateElement(element, update, false); mutateElement(element, update, false);
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
newSize: { width: newWidth, height: newHeight }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
@@ -1051,8 +1044,8 @@ const rotateMultipleElements = (
const origAngle = const origAngle =
originalElements.get(element.id)?.angle ?? element.angle; originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = pointRotateRads( const [rotatedCX, rotatedCY] = pointRotateRads(
pointFrom(cx, cy), point(cx, cy),
pointFrom(centerX, centerY), point(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
@@ -1108,44 +1101,40 @@ export const getResizeOffsetXY = (
const angle = ( const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0 selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians; ) as Radians;
[x, y] = pointRotateRads( [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
pointFrom(x, y),
pointFrom(cx, cy),
-angle as Radians,
);
switch (transformHandleType) { switch (transformHandleType) {
case "n": case "n":
return pointRotateRads( return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y1), point(x - (x1 + x2) / 2, y - y1),
pointFrom(0, 0), point(0, 0),
angle, angle,
); );
case "s": case "s":
return pointRotateRads( return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y2), point(x - (x1 + x2) / 2, y - y2),
pointFrom(0, 0), point(0, 0),
angle, angle,
); );
case "w": case "w":
return pointRotateRads( return pointRotateRads(
pointFrom(x - x1, y - (y1 + y2) / 2), point(x - x1, y - (y1 + y2) / 2),
pointFrom(0, 0), point(0, 0),
angle, angle,
); );
case "e": case "e":
return pointRotateRads( return pointRotateRads(
pointFrom(x - x2, y - (y1 + y2) / 2), point(x - x2, y - (y1 + y2) / 2),
pointFrom(0, 0), point(0, 0),
angle, angle,
); );
case "nw": case "nw":
return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle); return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
case "ne": case "ne":
return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle); return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
case "sw": case "sw":
return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle); return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
case "se": case "se":
return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle); return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
default: default:
return [0, 0]; return [0, 0];
} }
+15 -23
View File
@@ -20,10 +20,10 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants"; import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isImageElement, isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import { import {
pointFrom, point,
pointOnLineSegment, pointOnLineSegment,
pointRotateRads, pointRotateRads,
type Radians, type Radians,
@@ -90,26 +90,18 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
// do not resize from the sides for linear elements with only two points // do not resize from the sides for linear elements with only two points
if (!(isLinearElement(element) && element.points.length <= 2)) { if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = isImageElement(element) const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
? 0
: SIDE_RESIZING_THRESHOLD / zoom.value;
const ZOOMED_SIDE_RESIZING_THRESHOLD =
SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING), point(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING), point(x2 + SPACING, y2 + SPACING),
pointFrom(cx, cy), point(cx, cy),
element.angle, element.angle,
); );
for (const [dir, side] of Object.entries(sides)) { for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if ( if (
pointOnLineSegment( pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
pointFrom(x, y),
side as LineSegment<Point>,
ZOOMED_SIDE_RESIZING_THRESHOLD,
)
) { ) {
return dir as TransformHandleType; return dir as TransformHandleType;
} }
@@ -186,9 +178,9 @@ export const getTransformHandleTypeFromCoords = <
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING), point(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING), point(x2 + SPACING, y2 + SPACING),
pointFrom(cx, cy), point(cx, cy),
0 as Radians, 0 as Radians,
); );
@@ -196,7 +188,7 @@ export const getTransformHandleTypeFromCoords = <
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if ( if (
pointOnLineSegment( pointOnLineSegment(
pointFrom(scenePointerX, scenePointerY), point(scenePointerX, scenePointerY),
side as LineSegment<Point>, side as LineSegment<Point>,
SPACING, SPACING,
) )
@@ -273,10 +265,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
center: Point, center: Point,
angle: Radians, angle: Radians,
) => { ) => {
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle); const topLeft = pointRotateRads(point(x1, y1), center, angle);
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle); const topRight = pointRotateRads(point(x2, y1), center, angle);
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle); const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle); const bottomRight = pointRotateRads(point(x2, y2), center, angle);
return { return {
n: [topLeft, topRight], n: [topLeft, topRight],
+5 -5
View File
@@ -17,7 +17,7 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { ARROW_TYPE } from "../constants"; import { ARROW_TYPE } from "../constants";
import { pointFrom } from "../../math"; import { point } from "../../math";
const { h } = window; const { h } = window;
@@ -32,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
pointFrom(-45 - arrow.x, -100.1 - arrow.y), point(-45 - arrow.x, -100.1 - arrow.y),
pointFrom(45 - arrow.x, 99.9 - arrow.y), point(45 - arrow.x, 99.9 - arrow.y),
]); ]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -69,7 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1, y: -100.1,
width: 90, width: 90,
height: 200, height: 200,
points: [pointFrom(0, 0), pointFrom(90, 200)], points: [point(0, 0), point(90, 200)],
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1); scene.insertElement(rectangle1);
scene.insertElement(rectangle2); scene.insertElement(rectangle2);
@@ -81,7 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]); mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
+5 -5
View File
@@ -1,6 +1,6 @@
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
pointFrom, point,
pointScaleFromOrigin, pointScaleFromOrigin,
pointTranslate, pointTranslate,
vector, vector,
@@ -743,13 +743,13 @@ const getDonglePosition = (
): GlobalPoint => { ): GlobalPoint => {
switch (heading) { switch (heading) {
case HEADING_UP: case HEADING_UP:
return pointFrom(p[0], bounds[1]); return point(p[0], bounds[1]);
case HEADING_RIGHT: case HEADING_RIGHT:
return pointFrom(bounds[2], p[1]); return point(bounds[2], p[1]);
case HEADING_DOWN: case HEADING_DOWN:
return pointFrom(p[0], bounds[3]); return point(p[0], bounds[3]);
} }
return pointFrom(bounds[0], p[1]); return point(bounds[0], p[1]);
}; };
const estimateSegmentCount = ( const estimateSegmentCount = (
+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 { getLineHeight } from "../fonts";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
@@ -6,10 +6,235 @@ import {
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
wrapText,
detectLineHeight, detectLineHeight,
getLineHeightInPx, getLineHeightInPx,
parseTokens,
} from "./textElement"; } 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 measureText", () => {
describe("Test getContainerCoords", () => { describe("Test getContainerCoords", () => {
+170 -12
View File
@@ -21,7 +21,6 @@ import {
} from "../constants"; } from "../constants";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
import { isTextElement } from "."; import { isTextElement } from ".";
import { wrapText } from "./textWrapping";
import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { isBoundToContainer, isArrowElement } from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import type { AppState } from "../types"; 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. * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
*/ */
export const getLineWidth = ( const getLineWidth = (
text: string, text: string,
font: FontString, font: FontString,
forceAdvanceWidth?: true, forceAdvanceWidth?: true,
@@ -409,34 +408,193 @@ export const getTextHeight = (
return getLineHeightInPx(fontSize, lineHeight) * lineCount; 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 = (() => { export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {}; const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => { const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0); const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) { if (!cachedCharWidth[font]) {
cachedCharWidth[font] = []; cachedCharWidth[font] = [];
} }
if (!cachedCharWidth[font][unicode]) { if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font, true); 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) => { const getCache = (font: FontString) => {
return cachedCharWidth[font]; return cachedCharWidth[font];
}; };
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return { return {
calculate, calculate,
getCache, 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!");
}
}
};
@@ -19,7 +19,7 @@ import type {
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { pointFrom } from "../../math"; import { point } from "../../math";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -42,7 +42,7 @@ describe("textWysiwyg", () => {
type: "line", type: "line",
width: 100, width: 100,
height: 0, height: 0,
points: [pointFrom(0, 0), pointFrom(100, 0)], points: [point(0, 0), point(100, 0)],
}); });
const textSize = 20; const textSize = 20;
const text = API.createElement({ const text = API.createElement({
@@ -917,7 +917,7 @@ describe("textWysiwyg", () => {
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
text = h.elements[1] as ExcalidrawTextElementWithContainer; 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.originalText).toBe("Hello World!");
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
@@ -1220,7 +1220,7 @@ describe("textWysiwyg", () => {
); );
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe( 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, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
+9 -4
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, POINTER_BUTTON } from "../constants"; import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -27,13 +27,13 @@ import {
getTextWidth, getTextWidth,
normalizeText, normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
wrapText,
getBoundTextMaxHeight, getBoundTextMaxHeight,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
computeBoundTextPosition, computeBoundTextPosition,
getBoundTextElement, getBoundTextElement,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
@@ -245,6 +245,11 @@ export const textWysiwyg = ({
const font = getFontString(updatedTextElement); 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 // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value; (appState.height - viewportY) / appState.zoom.value;
@@ -254,7 +259,7 @@ export const textWysiwyg = ({
lineHeight: updatedTextElement.lineHeight, lineHeight: updatedTextElement.lineHeight,
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
left: `${viewportX}px`, left: `${viewportX - padding}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
transform: getTransform( transform: getTransform(
width, width,
@@ -264,6 +269,7 @@ export const textWysiwyg = ({
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
), ),
padding: `0 ${padding}px`,
textAlign, textAlign,
verticalAlign, verticalAlign,
color: updatedTextElement.strokeColor, color: updatedTextElement.strokeColor,
@@ -304,7 +310,6 @@ export const textWysiwyg = ({
minHeight: "1em", minHeight: "1em",
backfaceVisibility: "hidden", backfaceVisibility: "hidden",
margin: 0, margin: 0,
padding: 0,
border: 0, border: 0,
outline: 0, outline: 0,
resize: "none", resize: "none",
@@ -11,7 +11,6 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { import {
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement,
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
@@ -20,7 +19,7 @@ import {
isIOS, isIOS,
} from "../constants"; } from "../constants";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { pointFrom, pointRotateRads } from "../../math"; import { point, pointRotateRads } from "../../math";
export type TransformHandleDirection = export type TransformHandleDirection =
| "n" | "n"
@@ -96,8 +95,8 @@ const generateTransformHandle = (
angle: Radians, angle: Radians,
): TransformHandle => { ): TransformHandle => {
const [xx, yy] = pointRotateRads( const [xx, yy] = pointRotateRads(
pointFrom(x + width / 2, y + height / 2), point(x + width / 2, y + height / 2),
pointFrom(cx, cy), point(cx, cy),
angle, angle,
); );
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
@@ -130,7 +129,6 @@ export const getTransformHandlesFromCoords = (
pointerType: PointerType, pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4, margin = 4,
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
): TransformHandles => { ): TransformHandles => {
const size = transformHandleSizes[pointerType]; const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value; const handleWidth = size / zoom.value;
@@ -142,7 +140,8 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; const height = y2 - y1;
const dashedLineMargin = margin / zoom.value; const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - spacing * 2) / (2 * zoom.value); const centeringOffset =
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
const transformHandles: TransformHandles = { const transformHandles: TransformHandles = {
nw: omitSides.nw nw: omitSides.nw
@@ -302,10 +301,8 @@ export const getTransformHandles = (
rotation: true, rotation: true,
}; };
} }
const margin = isLinearElement(element) const dashedLineMargin = isLinearElement(element)
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
: isImageElement(element)
? 0
: DEFAULT_TRANSFORM_HANDLE_SPACING; : DEFAULT_TRANSFORM_HANDLE_SPACING;
return getTransformHandlesFromCoords( return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, elementsMap, true), getElementAbsoluteCoords(element, elementsMap, true),
@@ -313,8 +310,7 @@ export const getTransformHandles = (
zoom, zoom,
pointerType, pointerType,
omitSides, omitSides,
margin, dashedLineMargin,
isImageElement(element) ? 0 : undefined,
); );
}; };
-11
View File
@@ -132,15 +132,6 @@ export type IframeData =
| { type: "document"; srcdoc: (theme: Theme) => string } | { type: "document"; srcdoc: (theme: Theme) => string }
); );
export type ImageCrop = {
x: number;
y: number;
width: number;
height: number;
naturalWidth: number;
naturalHeight: number;
};
export type ExcalidrawImageElement = _ExcalidrawElementBase & export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "image"; type: "image";
@@ -149,8 +140,6 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
status: "pending" | "saved" | "error"; status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */ /** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number]; scale: [number, number];
/** whether an element is cropped */
crop: ImageCrop | null;
}>; }>;
export type InitializedExcalidrawImageElement = MarkNonNullable< export type InitializedExcalidrawImageElement = MarkNonNullable<
-37
View File
@@ -36,40 +36,3 @@ export class ImageSceneDataError extends Error {
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const; 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";
}
}

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