Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d346d24ccf | |||
| b2a6a87b10 | |||
| ab8b3537b3 | |||
| d21e0008dd | |||
| 840f1428c4 | |||
| 2db5bbcb29 | |||
| 0927431d0d | |||
| 98c0a67333 | |||
| 57cf577376 | |||
| 6e0ee89ee4 | |||
| 35f778a734 | |||
| ee091d0dbd | |||
| ef9ea14a75 | |||
| df168a6883 | |||
| 798f5f4dfb | |||
| d9ad7c039b | |||
| 7c0239e693 | |||
| da33481fa3 | |||
| 70e0e8dc29 | |||
| 2734e646ca | |||
| dfaaff4432 | |||
| 03028eaa8c | |||
| 79b181bcdc | |||
| f9815b8b4f | |||
| 96ed8a4331 | |||
| 33b01d4e80 | |||
| 7d52176fea | |||
| 958e03fcc6 | |||
| 4cedf3d966 | |||
| e957c8e9ee | |||
| eb09b48ae6 | |||
| 61623bbeba | |||
| 15ca182333 | |||
| b479f3bd65 | |||
| 21815fb930 | |||
| 47ee8a0094 | |||
| a977dd1bf5 | |||
| 3fe1883f3f | |||
| a80cb5896a | |||
| 6dfa18414a |
+12
-1
@@ -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=https://app.excalidraw.com
|
VITE_APP_PLUS_APP=http://localhost:3000
|
||||||
|
|
||||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||||
|
|
||||||
@@ -37,3 +37,14 @@ 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,3 +15,18 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ jobs:
|
|||||||
semantic:
|
semantic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<h4 align="center">
|
<h4 align="center">
|
||||||
<a href="https://excalidraw.com">Excalidraw Editor</a> |
|
<a href="https://excalidraw.com">Excalidraw Editor</a> |
|
||||||
<a href="https://blog.excalidraw.com">Blog</a> |
|
<a href="https://plus.excalidraw.com/blog">Blog</a> |
|
||||||
<a href="https://docs.excalidraw.com">Documentation</a> |
|
<a href="https://docs.excalidraw.com">Documentation</a> |
|
||||||
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
<a href="https://plus.excalidraw.com">Excalidraw+</a>
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -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 handrawn like diagrams."
|
alt="Excalidraw logo: Sketch hand-drawn like diagrams."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
### Center
|
### Center
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
|||||||
|
|
||||||
| Font Family | Description |
|
| Font Family | Description |
|
||||||
| ----------- | ---------------------- |
|
| ----------- | ---------------------- |
|
||||||
| `Virgil` | The `handwritten` font |
|
| `Virgil` | The `Hand-drawn` font |
|
||||||
| `Helvetica` | The `Normal` Font |
|
| `Helvetica` | The `Normal` Font |
|
||||||
| `Cascadia` | The `Code` Font |
|
| `Cascadia` | The `Code` Font |
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const config = {
|
|||||||
label: "Docs",
|
label: "Docs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "https://blog.excalidraw.com",
|
to: "https://plus.excalidraw.com/blog",
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
position: "left",
|
position: "left",
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ const config = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Blog",
|
label: "Blog",
|
||||||
to: "https://blog.excalidraw.com",
|
to: "https://plus.excalidraw.com/blog",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# copied assets
|
# copied assets
|
||||||
public/*.woff2
|
public/**/*.woff2
|
||||||
@@ -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 ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./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 ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
|
"copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./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"
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ import DebugCanvas, {
|
|||||||
loadSavedDebugState,
|
loadSavedDebugState,
|
||||||
} from "./components/DebugCanvas";
|
} from "./components/DebugCanvas";
|
||||||
import { AIComponents } from "./components/AI";
|
import { AIComponents } from "./components/AI";
|
||||||
|
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -1125,6 +1126,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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}>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -9,6 +10,7 @@ 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";
|
||||||
@@ -157,7 +159,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveFilesToFirebase({
|
const { savedFiles, erroredFiles } = await saveFilesToFirebase({
|
||||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||||
files: await encodeFilesForUpload({
|
files: await encodeFilesForUpload({
|
||||||
files: addedFiles,
|
files: addedFiles,
|
||||||
@@ -165,6 +167,29 @@ 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;
|
||||||
@@ -394,7 +419,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
!this.fileManager.isFileHandled(element.fileId) &&
|
!this.fileManager.isFileTracked(element.fileId) &&
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
(opts.forceFetchFiles
|
(opts.forceFetchFiles
|
||||||
? element.status !== "pending" ||
|
? element.status !== "pending" ||
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const EncryptedIcon = () => {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ 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<ExcalidrawImageElement["fileId"], true>();
|
private savingFiles = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
/* files already saved to persistent storage */
|
/* files already saved to persistent storage */
|
||||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
|
||||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private erroredFiles_save = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
|
|
||||||
private _getFiles;
|
private _getFiles;
|
||||||
private _saveFiles;
|
private _saveFiles;
|
||||||
@@ -37,8 +49,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, true>;
|
savedFiles: Map<FileId, BinaryFileData>;
|
||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, BinaryFileData>;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
this._getFiles = getFiles;
|
this._getFiles = getFiles;
|
||||||
@@ -46,19 +58,28 @@ export class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns whether file is already saved or being processed
|
* returns whether file is saved/errored, or being processed
|
||||||
*/
|
*/
|
||||||
isFileHandled = (id: FileId) => {
|
isFileTracked = (id: FileId) => {
|
||||||
return (
|
return (
|
||||||
this.savedFiles.has(id) ||
|
this.savedFiles.has(id) ||
|
||||||
this.fetchingFiles.has(id) ||
|
|
||||||
this.savingFiles.has(id) ||
|
this.savingFiles.has(id) ||
|
||||||
this.erroredFiles.has(id)
|
this.fetchingFiles.has(id) ||
|
||||||
|
this.erroredFiles_fetch.has(id) ||
|
||||||
|
this.erroredFiles_save.has(id)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
isFileSaved = (id: FileId) => {
|
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
|
||||||
return this.savedFiles.has(id);
|
const fileVersion = this.getFileVersion(file);
|
||||||
|
return (
|
||||||
|
this.savedFiles.get(file.id) === fileVersion ||
|
||||||
|
this.savingFiles.get(file.id) === fileVersion
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getFileVersion = (file: BinaryFileData) => {
|
||||||
|
return file.version ?? 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
saveFiles = async ({
|
saveFiles = async ({
|
||||||
@@ -71,13 +92,16 @@ 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 (
|
||||||
isInitializedImageElement(element) &&
|
fileData &&
|
||||||
files[element.fileId] &&
|
// NOTE if errored during save, won't retry due to this check
|
||||||
!this.isFileHandled(element.fileId)
|
!this.isFileSavedOrBeingSaved(fileData)
|
||||||
) {
|
) {
|
||||||
addedFiles.set(element.fileId, files[element.fileId]);
|
addedFiles.set(element.fileId, files[element.fileId]);
|
||||||
this.savingFiles.set(element.fileId, true);
|
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +110,12 @@ export class FileManager {
|
|||||||
addedFiles,
|
addedFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [fileId] of savedFiles) {
|
for (const [fileId, fileData] of savedFiles) {
|
||||||
this.savedFiles.set(fileId, true);
|
this.savedFiles.set(fileId, this.getFileVersion(fileData));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fileId, fileData] of erroredFiles) {
|
||||||
|
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -121,10 +149,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, true);
|
this.savedFiles.set(file.id, this.getFileVersion(file));
|
||||||
}
|
}
|
||||||
for (const [fileId] of erroredFiles) {
|
for (const [fileId] of erroredFiles) {
|
||||||
this.erroredFiles.set(fileId, true);
|
this.erroredFiles_fetch.set(fileId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadedFiles, erroredFiles };
|
return { loadedFiles, erroredFiles };
|
||||||
@@ -160,7 +188,7 @@ export class FileManager {
|
|||||||
): element is InitializedExcalidrawImageElement => {
|
): element is InitializedExcalidrawImageElement => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
this.isFileSaved(element.fileId) &&
|
this.savedFiles.has(element.fileId) &&
|
||||||
element.status === "pending"
|
element.status === "pending"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -169,7 +197,8 @@ export class FileManager {
|
|||||||
this.fetchingFiles.clear();
|
this.fetchingFiles.clear();
|
||||||
this.savingFiles.clear();
|
this.savingFiles.clear();
|
||||||
this.savedFiles.clear();
|
this.savedFiles.clear();
|
||||||
this.erroredFiles.clear();
|
this.erroredFiles_fetch.clear();
|
||||||
|
this.erroredFiles_save.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ export class LocalData {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
async saveFiles({ addedFiles }) {
|
async saveFiles({ addedFiles }) {
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles = new Map<FileId, BinaryFileData>();
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles = new Map<FileId, BinaryFileData>();
|
||||||
|
|
||||||
// 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, true);
|
savedFiles.set(id, fileData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.set(id, fileData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles: FileId[] = [];
|
||||||
|
|
||||||
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.set(id, true);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.push(id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,12 +54,6 @@
|
|||||||
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>
|
||||||
@@ -124,28 +118,19 @@
|
|||||||
<!-- 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" />
|
||||||
@@ -227,6 +212,7 @@
|
|||||||
</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
|
||||||
@@ -259,5 +245,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
|
<% } %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,20 +23,20 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
"vite-plugin-html": "3.2.2",
|
"socket.io-client": "4.7.2",
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"vite-plugin-html": "3.2.2"
|
||||||
"@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,5 +49,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -29,6 +29,9 @@ 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;
|
||||||
|
|||||||
+225
-203
@@ -5,215 +5,237 @@ 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";
|
||||||
|
|
||||||
// To load .env.local variables
|
export default defineConfig(({ mode }) => {
|
||||||
const envVars = loadEnv("", `../`);
|
// To load .env variables
|
||||||
// https://vitejs.dev/config/
|
const envVars = loadEnv(mode, `../`);
|
||||||
export default defineConfig({
|
// https://vitejs.dev/config/
|
||||||
server: {
|
return {
|
||||||
port: Number(envVars.VITE_APP_PORT || 3000),
|
server: {
|
||||||
// open the browser
|
port: Number(envVars.VITE_APP_PORT || 3000),
|
||||||
open: true,
|
// open the browser
|
||||||
},
|
open: true,
|
||||||
// We need to specify the envDir since now there are no
|
|
||||||
//more located in parallel with the vite.config.ts file but in parent dir
|
|
||||||
envDir: "../",
|
|
||||||
build: {
|
|
||||||
outDir: "build",
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
assetFileNames(chunkInfo) {
|
|
||||||
if (chunkInfo?.name?.endsWith(".woff2")) {
|
|
||||||
// put on root so we are flexible about the CDN path
|
|
||||||
return "[name]-[hash][extname]";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "assets/[name]-[hash][extname]";
|
|
||||||
},
|
|
||||||
// Creating separate chunk for locales except for en and percentages.json so they
|
|
||||||
// can be cached at runtime and not merged with
|
|
||||||
// app precache. en.json and percentages.json are needed for first load
|
|
||||||
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
|
||||||
manualChunks(id) {
|
|
||||||
if (
|
|
||||||
id.includes("packages/excalidraw/locales") &&
|
|
||||||
id.match(/en.json|percentages.json/) === null
|
|
||||||
) {
|
|
||||||
const index = id.indexOf("locales/");
|
|
||||||
// Taking the substring after "locales/"
|
|
||||||
return `locales/${id.substring(index + 8)}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
// We need to specify the envDir since now there are no
|
||||||
},
|
//more located in parallel with the vite.config.ts file but in parent dir
|
||||||
plugins: [
|
envDir: "../",
|
||||||
woff2BrowserPlugin(),
|
build: {
|
||||||
react(),
|
outDir: "build",
|
||||||
checker({
|
rollupOptions: {
|
||||||
typescript: true,
|
output: {
|
||||||
eslint:
|
assetFileNames(chunkInfo) {
|
||||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
? undefined
|
const family = chunkInfo.name.split("-")[0];
|
||||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
return `fonts/${family}/[name][extname]`;
|
||||||
overlay: {
|
}
|
||||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
|
||||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
svgrPlugin(),
|
|
||||||
ViteEjsPlugin(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: "autoUpdate",
|
|
||||||
devOptions: {
|
|
||||||
/* set this flag to true to enable in Development mode */
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
workbox: {
|
return "assets/[name]-[hash][extname]";
|
||||||
// Don't push fonts, locales and wasm to app precache
|
|
||||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
|
||||||
cacheName: "fonts",
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
// Creating separate chunk for locales except for en and percentages.json so they
|
||||||
urlPattern: new RegExp("fonts.css"),
|
// can be cached at runtime and not merged with
|
||||||
handler: "StaleWhileRevalidate",
|
// app precache. en.json and percentages.json are needed for first load
|
||||||
options: {
|
// or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
|
||||||
cacheName: "fonts",
|
manualChunks(id) {
|
||||||
expiration: {
|
if (
|
||||||
maxEntries: 50,
|
id.includes("packages/excalidraw/locales") &&
|
||||||
},
|
id.match(/en.json|percentages.json/) === null
|
||||||
},
|
) {
|
||||||
},
|
const index = id.indexOf("locales/");
|
||||||
{
|
// Taking the substring after "locales/"
|
||||||
urlPattern: new RegExp("locales/[^/]+.js"),
|
return `locales/${id.substring(index + 8)}`;
|
||||||
handler: "CacheFirst",
|
}
|
||||||
options: {
|
|
||||||
cacheName: "locales",
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: new RegExp(".wasm-.+.js"),
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
|
||||||
cacheName: "wasm",
|
|
||||||
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: "/",
|
|
||||||
display: "standalone",
|
|
||||||
theme_color: "#121212",
|
|
||||||
background_color: "#ffffff",
|
|
||||||
file_handlers: [
|
|
||||||
{
|
|
||||||
action: "/",
|
|
||||||
accept: {
|
|
||||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
share_target: {
|
|
||||||
action: "/web-share-target",
|
|
||||||
method: "POST",
|
|
||||||
enctype: "multipart/form-data",
|
|
||||||
params: {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
name: "file",
|
|
||||||
accept: [
|
|
||||||
"application/vnd.excalidraw+json",
|
|
||||||
"application/json",
|
|
||||||
".excalidraw",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
screenshots: [
|
|
||||||
{
|
|
||||||
src: "/screenshots/virtual-whiteboard.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
sourcemap: true,
|
||||||
createHtmlPlugin({
|
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||||
minify: true,
|
assetsInlineLimit: 0,
|
||||||
}),
|
},
|
||||||
],
|
plugins: [
|
||||||
publicDir: "../public",
|
Sitemap({
|
||||||
|
hostname: "https://excalidraw.com",
|
||||||
|
outDir: "build",
|
||||||
|
changefreq: "monthly",
|
||||||
|
// its static in public folder
|
||||||
|
generateRobotsTxt: false,
|
||||||
|
}),
|
||||||
|
woff2BrowserPlugin(),
|
||||||
|
react(),
|
||||||
|
checker({
|
||||||
|
typescript: true,
|
||||||
|
eslint:
|
||||||
|
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||||
|
? undefined
|
||||||
|
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||||
|
overlay: {
|
||||||
|
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||||
|
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
svgrPlugin(),
|
||||||
|
ViteEjsPlugin(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
devOptions: {
|
||||||
|
/* set this flag to true to enable in Development mode */
|
||||||
|
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
|
||||||
|
},
|
||||||
|
|
||||||
|
workbox: {
|
||||||
|
// don't precache fonts, locales and separate chunks
|
||||||
|
globIgnores: [
|
||||||
|
"fonts.css",
|
||||||
|
"**/locales/**",
|
||||||
|
"service-worker.js",
|
||||||
|
"**/*.chunk-*.js",
|
||||||
|
],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".+.woff2"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1000,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("fonts.css"),
|
||||||
|
handler: "StaleWhileRevalidate",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "locales",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".chunk-.+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "chunk",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
short_name: "Excalidraw",
|
||||||
|
name: "Excalidraw",
|
||||||
|
description:
|
||||||
|
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "android-chrome-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "apple-touch-icon.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-32x32.png",
|
||||||
|
sizes: "32x32",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-16x16.png",
|
||||||
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
start_url: "/",
|
||||||
|
id:"excalidraw",
|
||||||
|
display: "standalone",
|
||||||
|
theme_color: "#121212",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
file_handlers: [
|
||||||
|
{
|
||||||
|
action: "/",
|
||||||
|
accept: {
|
||||||
|
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
share_target: {
|
||||||
|
action: "/web-share-target",
|
||||||
|
method: "POST",
|
||||||
|
enctype: "multipart/form-data",
|
||||||
|
params: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
accept: [
|
||||||
|
"application/vnd.excalidraw+json",
|
||||||
|
"application/json",
|
||||||
|
".excalidraw",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
screenshots: [
|
||||||
|
{
|
||||||
|
src: "/screenshots/virtual-whiteboard.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
@@ -45,7 +45,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
@@ -55,15 +55,9 @@
|
|||||||
"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",
|
||||||
"fix:code": "yarn test:code --fix",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"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:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||||
"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 .",
|
||||||
@@ -74,9 +68,15 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ 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)
|
||||||
|
|||||||
@@ -147,14 +147,32 @@ export const actionCopyAsSvg = register({
|
|||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 { point } from "../../math";
|
import { pointFrom } 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
|
||||||
? point(firstPoint[0], firstPoint[1])
|
? pointFrom(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 { point } from "../../math";
|
import { pointFrom } 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: [
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
point(0, -35),
|
pointFrom(0, -35),
|
||||||
point(-90.9, -35),
|
pointFrom(-90.9, -35),
|
||||||
point(-90.9, 204.9),
|
pointFrom(-90.9, 204.9),
|
||||||
point(65.1, 204.9),
|
pointFrom(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 } from "../keys";
|
import { KEYS, matchKey } 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,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||||
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,
|
||||||
@@ -104,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] &&
|
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||||
event.shiftKey &&
|
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||||
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 { point, vector } from "../../math";
|
import { pointFrom, 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 =>
|
||||||
point(p[0] - newElement.x, p[1] - newElement.y),
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
),
|
),
|
||||||
vector(0, 0),
|
vector(0, 0),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -88,3 +88,5 @@ export { actionToggleElementLock } from "./actionElementLock";
|
|||||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
|
||||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||||
|
|
||||||
|
export { actionToggleCropEditor } from "./actionCropEditor";
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export type ShortcutName =
|
|||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
| "bringToFront"
|
| "bringToFront"
|
||||||
| "copyAsPng"
|
| "copyAsPng"
|
||||||
| "copyAsSvg"
|
|
||||||
| "group"
|
| "group"
|
||||||
| "ungroup"
|
| "ungroup"
|
||||||
| "gridMode"
|
| "gridMode"
|
||||||
@@ -88,7 +87,6 @@ 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+'")],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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 =
|
||||||
@@ -24,10 +23,7 @@ export type ActionSource =
|
|||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
| {
|
| {
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: MarkOptional<
|
appState?: Partial<AppState> | null;
|
||||||
AppState,
|
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
|
||||||
> | null;
|
|
||||||
files?: BinaryFiles | null;
|
files?: BinaryFiles | null;
|
||||||
storeAction: StoreActionType;
|
storeAction: StoreActionType;
|
||||||
replaceFiles?: boolean;
|
replaceFiles?: boolean;
|
||||||
@@ -138,7 +134,8 @@ export type ActionName =
|
|||||||
| "commandPalette"
|
| "commandPalette"
|
||||||
| "autoResize"
|
| "autoResize"
|
||||||
| "elementStats"
|
| "elementStats"
|
||||||
| "searchMenu";
|
| "searchMenu"
|
||||||
|
| "cropEditor";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export const getDefaultAppState = (): Omit<
|
|||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
followedBy: new Set(),
|
followedBy: new Set(),
|
||||||
|
isCropping: false,
|
||||||
|
croppingElementId: null,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -237,6 +239,8 @@ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ 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";
|
||||||
@@ -626,6 +629,18 @@ 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];
|
||||||
|
|
||||||
@@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
editingLinearElementId,
|
editingLinearElementId,
|
||||||
selectedLinearElementId,
|
selectedLinearElementId,
|
||||||
|
croppingElementId,
|
||||||
...standaloneProps
|
...standaloneProps
|
||||||
} = delta as ObservedAppState;
|
} = delta as ObservedAppState;
|
||||||
|
|
||||||
@@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
|
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||||
|
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.
|
||||||
@@ -1216,6 +1235,18 @@ 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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Radians } from "../math";
|
import type { Radians } from "../math";
|
||||||
import { point } from "../math";
|
import { pointFrom } from "../math";
|
||||||
import {
|
import {
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
DEFAULT_CHART_COLOR_INDEX,
|
DEFAULT_CHART_COLOR_INDEX,
|
||||||
@@ -260,7 +260,7 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
points: [point(0, 0), point(chartWidth, 0)],
|
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
const yLine = newLinearElement({
|
||||||
@@ -271,7 +271,7 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
points: [point(0, 0), point(0, -chartHeight)],
|
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
const maxLine = newLinearElement({
|
||||||
@@ -284,7 +284,7 @@ const chartLines = (
|
|||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [point(0, 0), point(chartWidth, 0)],
|
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||||
});
|
});
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
return [xLine, yLine, maxLine];
|
||||||
@@ -441,7 +441,7 @@ const chartTypeLine = (
|
|||||||
height: cy,
|
height: cy,
|
||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [point(0, 0), point(0, cy)],
|
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
@@ -127,6 +128,11 @@ 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>
|
||||||
@@ -245,6 +251,7 @@ 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
@@ -279,6 +279,7 @@ 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-handrawn",
|
testId: "font-family-hand-drawn",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: FONT_FAMILY.Nunito,
|
value: FONT_FAMILY.Nunito,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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;
|
||||||
@@ -62,12 +63,14 @@ export const FontPickerList = React.memo(
|
|||||||
const allFonts = useMemo(
|
const allFonts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Array.from(Fonts.registered.entries())
|
Array.from(Fonts.registered.entries())
|
||||||
.filter(([_, { metadata }]) => !metadata.serverSide)
|
.filter(
|
||||||
.map(([familyId, { metadata, fonts }]) => {
|
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
|
||||||
|
)
|
||||||
|
.map(([familyId, { metadata, fontFaces }]) => {
|
||||||
const fontDescriptor = {
|
const fontDescriptor = {
|
||||||
value: familyId,
|
value: familyId,
|
||||||
icon: metadata.icon,
|
icon: metadata.icon ?? FontFamilyNormalIcon,
|
||||||
text: fonts[0].fontFace.family,
|
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (metadata.deprecated) {
|
if (metadata.deprecated) {
|
||||||
@@ -89,7 +92,7 @@ export const FontPickerList = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sceneFamilies = useMemo(
|
const sceneFamilies = useMemo(
|
||||||
() => new Set(fonts.getSceneFontFamilies()),
|
() => new Set(fonts.getSceneFamilies()),
|
||||||
// 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],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Header = () => (
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
href="https://blog.excalidraw.com"
|
href="https://plus.excalidraw.com/blog"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -222,6 +222,16 @@ 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,6 +100,14 @@ 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 &&
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const ChartPreviewBtn = (props: {
|
|||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
},
|
},
|
||||||
null, // files
|
null, // files
|
||||||
|
{
|
||||||
|
skipInliningFonts: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
svg.querySelector(".style-fonts")?.remove();
|
svg.querySelector(".style-fonts")?.remove();
|
||||||
previewNode.replaceChildren();
|
previewNode.replaceChildren();
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ 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 { point, type GlobalPoint } from "../../../math";
|
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||||
|
|
||||||
interface MultiDimensionProps {
|
interface MultiDimensionProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
@@ -69,7 +69,6 @@ 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(
|
||||||
@@ -79,7 +78,7 @@ const resizeElementInGroup = (
|
|||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const newFontSize = boundTextElement.fontSize * scale;
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
newSize: { width: updates.width, height: updates.height },
|
||||||
});
|
});
|
||||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||||
@@ -182,7 +181,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
point(x1, y1),
|
pointFrom(x1, y1),
|
||||||
property,
|
property,
|
||||||
latestElements,
|
latestElements,
|
||||||
originalElements,
|
originalElements,
|
||||||
@@ -287,7 +286,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
point(x1, y1),
|
pointFrom(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 { point, pointRotateRads } from "../../../math";
|
import { pointFrom, 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(
|
||||||
point(origElement.x, origElement.y),
|
pointFrom(origElement.x, origElement.y),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,8 +97,8 @@ const moveGroupTo = (
|
|||||||
];
|
];
|
||||||
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
point(latestElement.x, latestElement.y),
|
pointFrom(latestElement.x, latestElement.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(origElement.x, origElement.y),
|
pointFrom(origElement.x, origElement.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(el.x, el.y),
|
pointFrom(el.x, el.y),
|
||||||
point(cx, cy),
|
pointFrom(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 { point, pointRotateRads } from "../../../math";
|
import { pointFrom, 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(
|
||||||
point(origElement.x, origElement.y),
|
pointFrom(origElement.x, origElement.y),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ const Position = ({
|
|||||||
appState,
|
appState,
|
||||||
}: PositionProps) => {
|
}: PositionProps) => {
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
point(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
pointFrom(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, point, pointRotateRads } from "../../../math";
|
import { degreesToRadians, pointFrom, 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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(rectangle.x, rectangle.y),
|
pointFrom(rectangle.x, rectangle.y),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Radians } from "../../../math";
|
import type { Radians } from "../../../math";
|
||||||
import { point, pointRotateRads } from "../../../math";
|
import { pointFrom, pointRotateRads } from "../../../math";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
@@ -151,8 +151,6 @@ export const resizeElement = (
|
|||||||
nextHeight = Math.max(nextHeight, minHeight);
|
nextHeight = Math.max(nextHeight, minHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width: oldWidth, height: oldHeight } = latestElement;
|
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
latestElement,
|
latestElement,
|
||||||
{
|
{
|
||||||
@@ -201,7 +199,7 @@ export const resizeElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBoundElements(latestElement, elementsMap, {
|
updateBoundElements(latestElement, elementsMap, {
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
newSize: { width: nextWidth, height: nextHeight },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (boundTextElement && boundTextFont) {
|
if (boundTextElement && boundTextFont) {
|
||||||
@@ -231,8 +229,8 @@ export const moveElement = (
|
|||||||
originalElement.y + originalElement.height / 2,
|
originalElement.y + originalElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
point(originalElement.x, originalElement.y),
|
pointFrom(originalElement.x, originalElement.y),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
originalElement.angle,
|
originalElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -240,8 +238,8 @@ export const moveElement = (
|
|||||||
const changeInY = newTopLeftY - topLeftY;
|
const changeInY = newTopLeftY - topLeftY;
|
||||||
|
|
||||||
const [x, y] = pointRotateRads(
|
const [x, y] = pointRotateRads(
|
||||||
point(newTopLeftX, newTopLeftY),
|
pointFrom(newTopLeftX, newTopLeftY),
|
||||||
point(cx + changeInX, cy + changeInY),
|
pointFrom(cx + changeInX, cy + changeInY),
|
||||||
-originalElement.angle as Radians,
|
-originalElement.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ 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 { point, type GlobalPoint } from "../../../math";
|
import { pointFrom, 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,
|
||||||
point(event.clientX, event.clientY),
|
pointFrom(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 { point, pointRotateRads } from "../../../math";
|
import { pointFrom, 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(
|
||||||
point(x + linkWidth / 2, y + linkHeight / 2),
|
pointFrom(x + linkWidth / 2, y + linkHeight / 2),
|
||||||
point(centerX, centerY),
|
pointFrom(centerX, centerY),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
@@ -85,5 +85,10 @@ export const isPointHittingLink = (
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
|
return isPointHittingLinkIcon(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
pointFrom(x, y),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2147,3 +2147,12 @@ 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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { COLOR_PALETTE } from "./colors";
|
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);
|
||||||
@@ -116,6 +117,9 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -136,6 +140,22 @@ 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",
|
||||||
@@ -157,8 +177,6 @@ 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;
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"backgroundColor": "#d8f5a2",
|
"backgroundColor": "#d8f5a2",
|
||||||
"boundElements": [
|
"boundElements": [
|
||||||
{
|
{
|
||||||
"id": "id45",
|
"id": "id47",
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "id46",
|
"id": "id48",
|
||||||
"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": "id46",
|
"id": "id48",
|
||||||
"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": "id47",
|
"elementId": "id49",
|
||||||
"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": "id45",
|
"id": "id47",
|
||||||
"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": "id48",
|
"id": "id50",
|
||||||
"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": "id48",
|
"id": "id50",
|
||||||
"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": "id49",
|
"id": "id51",
|
||||||
"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": "id48",
|
"containerId": "id50",
|
||||||
"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": "id38",
|
"id": "id40",
|
||||||
"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": "id40",
|
"elementId": "id42",
|
||||||
"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": "id39",
|
"elementId": "id41",
|
||||||
"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": "id37",
|
"containerId": "id39",
|
||||||
"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": "id37",
|
"id": "id39",
|
||||||
"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": "id37",
|
"id": "id39",
|
||||||
"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": "id42",
|
"id": "id44",
|
||||||
"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": "id44",
|
"elementId": "id46",
|
||||||
"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": "id43",
|
"elementId": "id45",
|
||||||
"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": "id41",
|
"containerId": "id43",
|
||||||
"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": "id41",
|
"id": "id43",
|
||||||
"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": "id41",
|
"id": "id43",
|
||||||
"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": "id54",
|
"id": "id56",
|
||||||
"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": "id55",
|
"id": "id57",
|
||||||
"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": "id56",
|
"id": "id58",
|
||||||
"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": "id57",
|
"id": "id59",
|
||||||
"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": "id58",
|
"id": "id60",
|
||||||
"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": "id59",
|
"id": "id61",
|
||||||
"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": 150,
|
"width": 140,
|
||||||
"x": 75,
|
"x": 80,
|
||||||
"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": 150,
|
"width": 140,
|
||||||
"x": 75,
|
"x": 80,
|
||||||
"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": 130,
|
"width": 120,
|
||||||
"x": 534.7893218813452,
|
"x": 539.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": 170,
|
"width": 160,
|
||||||
"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": 130,
|
"width": 120,
|
||||||
"x": 534.7893218813452,
|
"x": 539.7893218813452,
|
||||||
"y": 522.5735931288071,
|
"y": 522.5735931288071,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ 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) => {
|
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === MIME_TYPES.png) {
|
if (blob.type === MIME_TYPES.png) {
|
||||||
@@ -46,9 +47,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return (await import("./image")).decodeSvgMetadata({
|
||||||
await import("./image")
|
|
||||||
).decodeSvgMetadata({
|
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -249,6 +248,7 @@ 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,6 +261,16 @@ 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));
|
||||||
@@ -274,6 +284,10 @@ 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: {
|
||||||
|
|||||||
@@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption";
|
|||||||
// byte (binary) strings
|
// byte (binary) strings
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// fast, Buffer-compatible implem
|
// Buffer-compatible implem.
|
||||||
export const toByteString = (
|
//
|
||||||
data: string | Uint8Array | ArrayBuffer,
|
// Note that in V8, spreading the uint8array (by chunks) into
|
||||||
): Promise<string> => {
|
// `String.fromCharCode(...uint8array)` tends to be faster for large
|
||||||
return new Promise((resolve, reject) => {
|
// strings/buffers, in case perf is needed in the future.
|
||||||
const blob =
|
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
|
||||||
typeof data === "string"
|
const bytes =
|
||||||
? new Blob([new TextEncoder().encode(data)])
|
typeof data === "string"
|
||||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
? new TextEncoder().encode(data)
|
||||||
const reader = new FileReader();
|
: data instanceof Uint8Array
|
||||||
reader.onload = (event) => {
|
? data
|
||||||
if (!event.target || typeof event.target.result !== "string") {
|
: new Uint8Array(data);
|
||||||
return reject(new Error("couldn't convert to byte string"));
|
let bstring = "";
|
||||||
}
|
for (const byte of bytes) {
|
||||||
resolve(event.target.result);
|
bstring += String.fromCharCode(byte);
|
||||||
};
|
}
|
||||||
reader.readAsBinaryString(blob);
|
return bstring;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const byteStringToArrayBuffer = (byteString: string) => {
|
const byteStringToArrayBuffer = (byteString: string) => {
|
||||||
@@ -46,12 +45,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 = async (str: string, isByteString = false) => {
|
export const stringToBase64 = (str: string, isByteString = false) => {
|
||||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
|
||||||
};
|
};
|
||||||
|
|
||||||
// async to align with stringToBase64
|
// async to align with stringToBase64
|
||||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
export const base64ToString = (base64: string, isByteString = false) => {
|
||||||
return isByteString
|
return isByteString
|
||||||
? window.atob(base64)
|
? window.atob(base64)
|
||||||
: byteStringToString(window.atob(base64));
|
: byteStringToString(window.atob(base64));
|
||||||
@@ -66,6 +65,20 @@ 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
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -82,18 +95,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 = async ({
|
export const encode = ({
|
||||||
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;
|
||||||
}): Promise<EncodedData> => {
|
}): EncodedData => {
|
||||||
let deflated!: string;
|
let deflated!: string;
|
||||||
if (compress !== false) {
|
if (compress !== false) {
|
||||||
try {
|
try {
|
||||||
deflated = await toByteString(deflate(text));
|
deflated = toByteString(deflate(text));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("encode: cannot deflate", error);
|
console.error("encode: cannot deflate", error);
|
||||||
}
|
}
|
||||||
@@ -102,11 +115,11 @@ export const encode = async ({
|
|||||||
version: "1",
|
version: "1",
|
||||||
encoding: "bstring",
|
encoding: "bstring",
|
||||||
compressed: !!deflated,
|
compressed: !!deflated,
|
||||||
encoded: deflated || (await toByteString(text)),
|
encoded: deflated || toByteString(text),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decode = async (data: EncodedData): Promise<string> => {
|
export const decode = (data: EncodedData): string => {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
|
|
||||||
switch (data.encoding) {
|
switch (data.encoding) {
|
||||||
@@ -114,7 +127,7 @@ export const decode = async (data: EncodedData): Promise<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
|
||||||
: await byteStringToString(data.encoded);
|
: byteStringToString(data.encoded);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
||||||
|
|||||||
@@ -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(
|
||||||
await encode({
|
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 await decode(encodedData);
|
return 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 = async ({ text }: { text: string }) => {
|
export const encodeSvgMetadata = ({ text }: { text: string }) => {
|
||||||
const base64 = await stringToBase64(
|
const base64 = stringToBase64(
|
||||||
JSON.stringify(await encode({ text })),
|
JSON.stringify(encode({ text })),
|
||||||
true /* is already byte string */,
|
true /* is already byte string */,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
|||||||
return metadata;
|
return metadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
export const decodeSvgMetadata = ({ 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 = async ({ svg }: { svg: string }) => {
|
|||||||
const isByteString = version !== "1";
|
const isByteString = version !== "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = await base64ToString(match[1], isByteString);
|
const json = 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 = async ({ svg }: { svg: string }) => {
|
|||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return decode(encodedData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
|
|||||||
@@ -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, point } from "../../math";
|
import { isFiniteNumber, pointFrom } from "../../math";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -190,6 +190,10 @@ 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,
|
||||||
@@ -258,6 +262,7 @@ 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
|
||||||
@@ -268,7 +273,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
|
||||||
? [point(0, 0), point(element.width, element.height)]
|
? [pointFrom(0, 0), pointFrom(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) {
|
||||||
@@ -296,7 +301,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
|
||||||
? [point(0, 0), point(element.width, element.height)]
|
? [pointFrom(0, 0), pointFrom(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) {
|
||||||
|
|||||||
@@ -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 { point } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
|
|
||||||
const opts = { regenerateIds: false };
|
const opts = { regenerateIds: false };
|
||||||
|
|
||||||
@@ -309,28 +309,32 @@ 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"],
|
||||||
@@ -352,28 +356,9 @@ describe("Test Transform", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should consider max of calculated and frame dimensions when provided", () => {
|
it("should consider user defined frame dimensions over calculated 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"],
|
||||||
@@ -388,7 +373,27 @@ 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(126);
|
expect(frame.height).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -912,7 +917,7 @@ describe("Test Transform", () => {
|
|||||||
x: 111.262,
|
x: 111.262,
|
||||||
y: 57,
|
y: 57,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
points: [point(0, 0), point(272.985, 0)],
|
points: [pointFrom(0, 0), pointFrom(272.985, 0)],
|
||||||
label: {
|
label: {
|
||||||
text: "How are you?",
|
text: "How are you?",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -935,7 +940,7 @@ describe("Test Transform", () => {
|
|||||||
x: 77.017,
|
x: 77.017,
|
||||||
y: 79,
|
y: 79,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
points: [point(0, 0)],
|
points: [pointFrom(0, 0)],
|
||||||
label: {
|
label: {
|
||||||
text: "Friendship",
|
text: "Friendship",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
assertNever,
|
assertNever,
|
||||||
cloneJSON,
|
cloneJSON,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
isDevEnv,
|
||||||
toBrandedType,
|
toBrandedType,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
@@ -53,7 +54,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 { point, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
@@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
|
|||||||
excalidrawElement = newLinearElement({
|
excalidrawElement = newLinearElement({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
points: [point(0, 0), point(width, height)],
|
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||||
...element,
|
...element,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
points: [point(0, 0), point(width, height)],
|
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||||
...element,
|
...element,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
});
|
});
|
||||||
@@ -717,7 +718,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 possibe after all
|
// need to calculate coordinates and dimensions of frame which is possible 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") {
|
||||||
@@ -764,10 +765,26 @@ export const convertToExcalidrawElements = (
|
|||||||
maxX = maxX + PADDING;
|
maxX = maxX + PADDING;
|
||||||
maxY = maxY + PADDING;
|
maxY = maxY + PADDING;
|
||||||
|
|
||||||
// Take the max of calculated and provided frame dimensions, whichever is higher
|
const frameX = frame?.x || minX;
|
||||||
const width = Math.max(frame?.width, maxX - minX);
|
const frameY = frame?.y || minY;
|
||||||
const height = Math.max(frame?.height, maxY - minY);
|
const frameWidth = frame?.width || maxX - minX;
|
||||||
Object.assign(frame, { x: minX, y: minY, width, height });
|
const frameHeight = frame?.height || maxY - minY;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import {
|
|||||||
import type { LocalPoint, Radians } from "../../math";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import {
|
import {
|
||||||
lineSegment,
|
lineSegment,
|
||||||
point,
|
pointFrom,
|
||||||
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[];
|
||||||
oldSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
|
const { newSize, 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,
|
||||||
oldSize,
|
newSize,
|
||||||
),
|
),
|
||||||
endBinding: maybeCalculateNewGapWhenScaling(
|
endBinding: maybeCalculateNewGapWhenScaling(
|
||||||
changedElement,
|
changedElement,
|
||||||
element.endBinding,
|
element.endBinding,
|
||||||
oldSize,
|
newSize,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -720,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
return vectorToHeading(
|
return vectorToHeading(
|
||||||
vectorFromPoint(
|
vectorFromPoint(
|
||||||
p,
|
p,
|
||||||
point<GlobalPoint>(
|
pointFrom<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,
|
||||||
point(p[0], p[1] - 2 * bindableElement.height),
|
pointFrom(p[0], p[1] - 2 * bindableElement.height),
|
||||||
point(p[0], p[1] + 2 * bindableElement.height),
|
pointFrom(p[0], p[1] + 2 * bindableElement.height),
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
) ?? []),
|
) ?? []),
|
||||||
...(intersectElementWithLine(
|
...(intersectElementWithLine(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
point(p[0] - 2 * bindableElement.width, p[1]),
|
pointFrom(p[0] - 2 * bindableElement.width, p[1]),
|
||||||
point(p[0] + 2 * bindableElement.width, p[1]),
|
pointFrom(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(
|
||||||
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
pointFrom((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(
|
||||||
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
pointFrom(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(
|
||||||
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||||
center,
|
center,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
pointFrom(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 = point<GlobalPoint>(
|
const center = pointFrom<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>(
|
||||||
point(element.x - FIXED_BINDING_DISTANCE, element.y),
|
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(element.x, element.y - FIXED_BINDING_DISTANCE),
|
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -871,13 +871,16 @@ 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(
|
||||||
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
|
pointFrom(
|
||||||
|
element.x,
|
||||||
|
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||||
|
),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -891,7 +894,7 @@ export const avoidRectangularCorner = (
|
|||||||
element.width + FIXED_BINDING_DISTANCE
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(
|
pointFrom(
|
||||||
element.x + element.width,
|
element.x + element.width,
|
||||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||||
),
|
),
|
||||||
@@ -900,7 +903,7 @@ export const avoidRectangularCorner = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(
|
pointFrom(
|
||||||
element.x + element.width + FIXED_BINDING_DISTANCE,
|
element.x + element.width + FIXED_BINDING_DISTANCE,
|
||||||
element.y + element.height,
|
element.y + element.height,
|
||||||
),
|
),
|
||||||
@@ -917,13 +920,16 @@ export const avoidRectangularCorner = (
|
|||||||
element.width + FIXED_BINDING_DISTANCE
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
|
pointFrom(
|
||||||
|
element.x + element.width,
|
||||||
|
element.y - FIXED_BINDING_DISTANCE,
|
||||||
|
),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -938,7 +944,10 @@ 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 = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
const center = pointFrom<GlobalPoint>(
|
||||||
|
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
|
||||||
@@ -953,7 +962,7 @@ export const snapToMid = (
|
|||||||
) {
|
) {
|
||||||
// LEFT
|
// LEFT
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x - FIXED_BINDING_DISTANCE, center[1]),
|
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -964,7 +973,7 @@ export const snapToMid = (
|
|||||||
) {
|
) {
|
||||||
// TOP
|
// TOP
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(center[0], y - FIXED_BINDING_DISTANCE),
|
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -975,7 +984,7 @@ export const snapToMid = (
|
|||||||
) {
|
) {
|
||||||
// RIGHT
|
// RIGHT
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -986,7 +995,7 @@ export const snapToMid = (
|
|||||||
) {
|
) {
|
||||||
// DOWN
|
// DOWN
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(center[0], y + height + FIXED_BINDING_DISTANCE),
|
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -1023,11 +1032,11 @@ const updateBoundPoint = (
|
|||||||
startOrEnd === "startBinding" ? "start" : "end",
|
startOrEnd === "startBinding" ? "start" : "end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
const globalMidPoint = point<GlobalPoint>(
|
const globalMidPoint = pointFrom<GlobalPoint>(
|
||||||
bindableElement.x + bindableElement.width / 2,
|
bindableElement.x + bindableElement.width / 2,
|
||||||
bindableElement.y + bindableElement.height / 2,
|
bindableElement.y + bindableElement.height / 2,
|
||||||
);
|
);
|
||||||
const global = point<GlobalPoint>(
|
const global = pointFrom<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,
|
||||||
);
|
);
|
||||||
@@ -1118,7 +1127,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
hoveredElement,
|
hoveredElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const globalMidPoint = point(
|
const globalMidPoint = pointFrom(
|
||||||
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,
|
||||||
);
|
);
|
||||||
@@ -1337,9 +1346,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(point(x, y), shape, threshold) ||
|
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||||
(fullShape === true &&
|
(fullShape === true &&
|
||||||
pointInsideBounds(point(x, y), aabbForElement(element)))
|
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2197,11 +2206,11 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(
|
pointFrom(
|
||||||
element.x + element.width * fixedX,
|
element.x + element.width * fixedX,
|
||||||
element.y + element.height * fixedY,
|
element.y + element.height * fixedY,
|
||||||
),
|
),
|
||||||
point<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
element.x + element.width / 2,
|
element.x + element.width / 2,
|
||||||
element.y + element.height / 2,
|
element.y + element.height / 2,
|
||||||
),
|
),
|
||||||
@@ -2229,7 +2238,7 @@ const getGlobalFixedPoints = (
|
|||||||
arrow.startBinding.fixedPoint,
|
arrow.startBinding.fixedPoint,
|
||||||
startElement as ExcalidrawBindableElement,
|
startElement as ExcalidrawBindableElement,
|
||||||
)
|
)
|
||||||
: point<GlobalPoint>(
|
: pointFrom<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],
|
||||||
);
|
);
|
||||||
@@ -2239,7 +2248,7 @@ const getGlobalFixedPoints = (
|
|||||||
arrow.endBinding.fixedPoint,
|
arrow.endBinding.fixedPoint,
|
||||||
endElement as ExcalidrawBindableElement,
|
endElement as ExcalidrawBindableElement,
|
||||||
)
|
)
|
||||||
: point<GlobalPoint>(
|
: pointFrom<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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { point } from "../../math";
|
import { pointFrom } 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: [
|
||||||
point<LocalPoint>(0, 0),
|
pointFrom<LocalPoint>(0, 0),
|
||||||
point<LocalPoint>(67.33984375, 92.48828125),
|
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||||
point<LocalPoint>(-102.7890625, 52.15625),
|
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||||
],
|
],
|
||||||
} as ExcalidrawLinearElement;
|
} as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
point,
|
pointFrom,
|
||||||
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(
|
||||||
point(x, y),
|
pointFrom(x, y),
|
||||||
point(cx - element.x, cy - element.y),
|
pointFrom(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(
|
||||||
point(cx, y1),
|
pointFrom(cx, y1),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x12, y12] = pointRotateRads(
|
const [x12, y12] = pointRotateRads(
|
||||||
point(cx, y2),
|
pointFrom(cx, y2),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x22, y22] = pointRotateRads(
|
const [x22, y22] = pointRotateRads(
|
||||||
point(x1, cy),
|
pointFrom(x1, cy),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x21, y21] = pointRotateRads(
|
const [x21, y21] = pointRotateRads(
|
||||||
point(x2, cy),
|
pointFrom(x2, cy),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(x1, y1),
|
pointFrom(x1, y1),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x12, y12] = pointRotateRads(
|
const [x12, y12] = pointRotateRads(
|
||||||
point(x1, y2),
|
pointFrom(x1, y2),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x22, y22] = pointRotateRads(
|
const [x22, y22] = pointRotateRads(
|
||||||
point(x2, y2),
|
pointFrom(x2, y2),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const [x21, y21] = pointRotateRads(
|
const [x21, y21] = pointRotateRads(
|
||||||
point(x2, y1),
|
pointFrom(x2, y1),
|
||||||
point(cx, cy),
|
pointFrom(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 = point(cx, cy);
|
const center: GlobalPoint = pointFrom(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(
|
||||||
point(
|
pointFrom(
|
||||||
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(
|
||||||
point(
|
pointFrom(
|
||||||
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 = point(0, 0);
|
let currentP: GlobalPoint = pointFrom(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 = point<GlobalPoint>(data[0], data[1]);
|
const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
|
||||||
const _p2 = point<GlobalPoint>(data[2], data[3]);
|
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
|
||||||
const _p3 = point<GlobalPoint>(data[4], data[5]);
|
const _p3 = pointFrom<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 = point(data[4], data[5]);
|
const p3 = pointFrom(data[4], data[5]);
|
||||||
const p2 = point(data[2], data[3]);
|
const p2 = pointFrom(data[2], data[3]);
|
||||||
const p1 = point(data[0], data[1]);
|
const p1 = pointFrom(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 = point(0, 0);
|
let p0 = pointFrom(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 = point(prevOp.data[4], prevOp.data[5]);
|
p0 = pointFrom(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(
|
||||||
point(xs, ys),
|
pointFrom(xs, ys),
|
||||||
point(x2, y2),
|
pointFrom(x2, y2),
|
||||||
((-angle * Math.PI) / 180) as Radians,
|
((-angle * Math.PI) / 180) as Radians,
|
||||||
);
|
);
|
||||||
const [x4, y4] = pointRotateRads(
|
const [x4, y4] = pointRotateRads(
|
||||||
point(xs, ys),
|
pointFrom(xs, ys),
|
||||||
point(x2, y2),
|
pointFrom(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(
|
||||||
point(x2 + minSize * 2, y2),
|
pointFrom(x2 + minSize * 2, y2),
|
||||||
point(x2, y2),
|
pointFrom(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(
|
||||||
point(x2 - minSize * 2, y2),
|
pointFrom(x2 - minSize * 2, y2),
|
||||||
point(x2, y2),
|
pointFrom(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(
|
||||||
point(element.x + pointX, element.y + pointY),
|
pointFrom(element.x + pointX, element.y + pointY),
|
||||||
point(cx, cy),
|
pointFrom(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>(
|
||||||
point(element.x + x, element.y + y),
|
pointFrom(element.x + x, element.y + y),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point((x1 + x2) / 2, (y1 + y2) / 2),
|
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
|
||||||
point(from.x, from.y),
|
pointFrom(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 =>
|
||||||
point(
|
pointFrom(
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, point } from "../../math";
|
import { isPointWithinBounds, pointFrom } 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(point(x, y), shape) ||
|
isPointInShape(pointFrom(x, y), shape) ||
|
||||||
isPointOnShape(point(x, y), shape, threshold)
|
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||||
: isPointOnShape(point(x, y), shape, threshold);
|
: isPointOnShape(pointFrom(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(point(x, y), {
|
hit = isPointInShape(pointFrom(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,7 +89,11 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
|
return isPointWithinBounds(
|
||||||
|
pointFrom(x1, y1),
|
||||||
|
pointFrom(x, y),
|
||||||
|
pointFrom(x2, y2),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = <
|
export const hitElementBoundingBoxOnly = <
|
||||||
@@ -115,5 +119,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(point(x, y), textShape);
|
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
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,6 +16,7 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
|
isImageElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
@@ -251,6 +252,14 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -259,6 +268,7 @@ export const dragNewElement = ({
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
...textAutoResize,
|
...textAutoResize,
|
||||||
|
...imageInitialDimension,
|
||||||
},
|
},
|
||||||
informMutation,
|
informMutation,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 "./textElement";
|
import { wrapText } from "./textWrapping";
|
||||||
import { isIframeElement } from "./typeChecks";
|
import { isIframeElement } from "./typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -45,6 +45,12 @@ 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",
|
||||||
@@ -59,6 +65,7 @@ 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([
|
||||||
@@ -71,6 +78,7 @@ 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) => {
|
||||||
@@ -218,6 +226,24 @@ 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(
|
||||||
@@ -361,6 +387,11 @@ 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];
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { invariant } from "../utils";
|
import { invariant } from "../utils";
|
||||||
import { point, type LocalPoint } from "../../math";
|
import { pointFrom, 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: [point(0, 0), point(endX, endY)],
|
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
Radians,
|
Radians,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import {
|
import {
|
||||||
point,
|
pointFrom,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointScaleFromOrigin,
|
pointScaleFromOrigin,
|
||||||
radiansToDegrees,
|
radiansToDegrees,
|
||||||
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
|
|||||||
|
|
||||||
const top = pointRotateRads(
|
const top = pointRotateRads(
|
||||||
pointScaleFromOrigin(
|
pointScaleFromOrigin(
|
||||||
point(element.x + element.width / 2, element.y),
|
pointFrom(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(
|
||||||
point(element.x + element.width, element.y + element.height / 2),
|
pointFrom(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(
|
||||||
point(element.x + element.width / 2, element.y + element.height),
|
pointFrom(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(
|
||||||
point(element.x, element.y + element.height / 2),
|
pointFrom(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(
|
||||||
point(aabb[0], aabb[1]),
|
pointFrom(aabb[0], aabb[1]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
const topRight = pointScaleFromOrigin(
|
const topRight = pointScaleFromOrigin(
|
||||||
point(aabb[2], aabb[1]),
|
pointFrom(aabb[2], aabb[1]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
const bottomLeft = pointScaleFromOrigin(
|
const bottomLeft = pointScaleFromOrigin(
|
||||||
point(aabb[0], aabb[3]),
|
pointFrom(aabb[0], aabb[3]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
const bottomRight = pointScaleFromOrigin(
|
const bottomRight = pointScaleFromOrigin(
|
||||||
point(aabb[2], aabb[3]),
|
pointFrom(aabb[2], aabb[3]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
|
|||||||
@@ -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 = async (SVGString: string) => {
|
export const normalizeSVG = (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,
|
||||||
point,
|
pointFrom,
|
||||||
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], point(0, 0))) {
|
if (!pointsEqual(element.points[0], pointFrom(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,
|
||||||
point(scenePointerX, scenePointerY),
|
pointFrom(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: point(
|
point: pointFrom(
|
||||||
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(),
|
||||||
)
|
)
|
||||||
: point(
|
: pointFrom(
|
||||||
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(
|
||||||
point(
|
pointFrom(
|
||||||
existingSegmentMidpointHitCoords[0],
|
existingSegmentMidpointHitCoords[0],
|
||||||
existingSegmentMidpointHitCoords[1],
|
existingSegmentMidpointHitCoords[1],
|
||||||
),
|
),
|
||||||
point(scenePointer.x, scenePointer.y),
|
pointFrom(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(
|
||||||
point(midPoints[index]![0], midPoints[index]![1]),
|
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||||
point(scenePointer.x, scenePointer.y),
|
pointFrom(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(
|
||||||
point(startPoint[0], startPoint[1]),
|
pointFrom(startPoint[0], startPoint[1]),
|
||||||
point(endPoint[0], endPoint[1]),
|
pointFrom(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(
|
||||||
point(
|
pointFrom(
|
||||||
element.x + element.points[clickedPointIndex][0],
|
element.x + element.points[clickedPointIndex][0],
|
||||||
element.y + element.points[clickedPointIndex][1],
|
element.y + element.points[clickedPointIndex][1],
|
||||||
),
|
),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -928,11 +928,11 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
lastCommittedPoint,
|
lastCommittedPoint,
|
||||||
point(scenePointerX, scenePointerY),
|
pointFrom(scenePointerX, scenePointerY),
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
newPoint = point(
|
newPoint = pointFrom(
|
||||||
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(
|
||||||
point(x + p[0], y + p[1]),
|
pointFrom(x + p[0], y + p[1]),
|
||||||
point(cx, cy),
|
pointFrom(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(
|
||||||
point(x + p[0], y + p[1]),
|
pointFrom(x + p[0], y + p[1]),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1025,8 +1025,12 @@ export class LinearElementEditor {
|
|||||||
const { x, y } = element;
|
const { x, y } = element;
|
||||||
|
|
||||||
return p
|
return p
|
||||||
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
|
? pointRotateRads(
|
||||||
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
|
pointFrom(x + p[0], y + p[1]),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
)
|
||||||
|
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static pointFromAbsoluteCoords(
|
static pointFromAbsoluteCoords(
|
||||||
@@ -1036,7 +1040,7 @@ export class LinearElementEditor {
|
|||||||
): LocalPoint {
|
): LocalPoint {
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
// No rotation for elbow arrows
|
// No rotation for elbow arrows
|
||||||
return point(
|
return pointFrom(
|
||||||
absoluteCoords[0] - element.x,
|
absoluteCoords[0] - element.x,
|
||||||
absoluteCoords[1] - element.y,
|
absoluteCoords[1] - element.y,
|
||||||
);
|
);
|
||||||
@@ -1046,11 +1050,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(
|
||||||
point(absoluteCoords[0], absoluteCoords[1]),
|
pointFrom(absoluteCoords[0], absoluteCoords[1]),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
return point(x - element.x, y - element.y);
|
return pointFrom(x - element.x, y - element.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPointIndexUnderCursor(
|
static getPointIndexUnderCursor(
|
||||||
@@ -1071,7 +1075,7 @@ export class LinearElementEditor {
|
|||||||
while (--idx > -1) {
|
while (--idx > -1) {
|
||||||
const p = pointHandles[idx];
|
const p = pointHandles[idx];
|
||||||
if (
|
if (
|
||||||
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
|
pointDistance(pointFrom(x, y), pointFrom(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
|
||||||
) {
|
) {
|
||||||
@@ -1093,12 +1097,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(
|
||||||
point(pointerOnGrid[0], pointerOnGrid[1]),
|
pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
return point(rotatedX - element.x, rotatedY - element.y);
|
return pointFrom(rotatedX - element.x, rotatedY - element.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1118,7 +1122,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
points: points.map((p) => {
|
points: points.map((p) => {
|
||||||
return point(p[0] - offsetX, p[1] - offsetY);
|
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||||
}),
|
}),
|
||||||
x: element.x + offsetX,
|
x: element.x + offsetX,
|
||||||
y: element.y + offsetY,
|
y: element.y + offsetY,
|
||||||
@@ -1172,8 +1176,8 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
acc.push(
|
acc.push(
|
||||||
nextPoint
|
nextPoint
|
||||||
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||||
: point(p[0], p[1]),
|
: pointFrom(p[0], p[1]),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextSelectedIndices.push(indexCursor + 1);
|
nextSelectedIndices.push(indexCursor + 1);
|
||||||
@@ -1194,7 +1198,7 @@ export class LinearElementEditor {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
index: element.points.length - 1,
|
index: element.points.length - 1,
|
||||||
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
|
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -1235,7 +1239,9 @@ 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 ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
|
!acc.length
|
||||||
|
? pointFrom(0, 0)
|
||||||
|
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -1312,9 +1318,9 @@ export class LinearElementEditor {
|
|||||||
const deltaY =
|
const deltaY =
|
||||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||||
|
|
||||||
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||||
}
|
}
|
||||||
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
|
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
|
||||||
});
|
});
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
@@ -1368,8 +1374,8 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const origin = linearElementEditor.pointerDownState.origin!;
|
const origin = linearElementEditor.pointerDownState.origin!;
|
||||||
const dist = pointDistance(
|
const dist = pointDistance(
|
||||||
point(origin.x, origin.y),
|
pointFrom(origin.x, origin.y),
|
||||||
point(pointerCoords.x, pointerCoords.y),
|
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.editingLinearElement &&
|
||||||
@@ -1493,8 +1499,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(
|
||||||
point(offsetX, offsetY),
|
pointFrom(offsetX, offsetY),
|
||||||
point(dX, dY),
|
pointFrom(dX, dY),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
@@ -1540,8 +1546,8 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(width, height),
|
pointFrom(width, height),
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1611,36 +1617,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 = point(cx, cy);
|
const centerPoint = pointFrom(cx, cy);
|
||||||
|
|
||||||
const topLeftRotatedPoint = pointRotateRads(
|
const topLeftRotatedPoint = pointRotateRads(
|
||||||
point(x1, y1),
|
pointFrom(x1, y1),
|
||||||
centerPoint,
|
centerPoint,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const topRightRotatedPoint = pointRotateRads(
|
const topRightRotatedPoint = pointRotateRads(
|
||||||
point(x2, y1),
|
pointFrom(x2, y1),
|
||||||
centerPoint,
|
centerPoint,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
const counterRotateBoundTextTopLeft = pointRotateRads(
|
const counterRotateBoundTextTopLeft = pointRotateRads(
|
||||||
point(boundTextX1, boundTextY1),
|
pointFrom(boundTextX1, boundTextY1),
|
||||||
centerPoint,
|
centerPoint,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextTopRight = pointRotateRads(
|
const counterRotateBoundTextTopRight = pointRotateRads(
|
||||||
point(boundTextX2, boundTextY1),
|
pointFrom(boundTextX2, boundTextY1),
|
||||||
centerPoint,
|
centerPoint,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
||||||
point(boundTextX1, boundTextY2),
|
pointFrom(boundTextX1, boundTextY2),
|
||||||
centerPoint,
|
centerPoint,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextBottomRight = pointRotateRads(
|
const counterRotateBoundTextBottomRight = pointRotateRads(
|
||||||
point(boundTextX2, boundTextY2),
|
pointFrom(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 { point } from "../../math";
|
import { pointFrom } 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: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
|
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element);
|
const copy = duplicateElement(null, new Map(), element);
|
||||||
|
|||||||
@@ -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,6 +477,7 @@ 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 {
|
||||||
@@ -487,6 +488,7 @@ 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
point,
|
pointFrom,
|
||||||
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(
|
||||||
point(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
point(cx, cy),
|
pointFrom(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 = point<GlobalPoint>(x1, y1);
|
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||||
newTopLeft = point<GlobalPoint>(
|
newTopLeft = pointFrom<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 = point<GlobalPoint>(
|
newTopLeft = pointFrom<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 = point<GlobalPoint>(
|
newTopLeft = pointFrom<GlobalPoint>(
|
||||||
topRight[0] - Math.abs(nextWidth),
|
topRight[0] - Math.abs(nextWidth),
|
||||||
topRight[1],
|
topRight[1],
|
||||||
);
|
);
|
||||||
@@ -311,12 +311,20 @@ const resizeSingleTextElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const angle = element.angle;
|
const angle = element.angle;
|
||||||
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
|
const rotatedTopLeft = pointRotateRads(
|
||||||
const newCenter = point<GlobalPoint>(
|
newTopLeft,
|
||||||
|
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(newCenter, point(cx, cy), angle);
|
const rotatedNewCenter = pointRotateRads(
|
||||||
|
newCenter,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
newTopLeft = pointRotateRads(
|
newTopLeft = pointRotateRads(
|
||||||
rotatedTopLeft,
|
rotatedTopLeft,
|
||||||
rotatedNewCenter,
|
rotatedNewCenter,
|
||||||
@@ -341,12 +349,12 @@ const resizeSingleTextElement = (
|
|||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft = point<GlobalPoint>(x1, y1);
|
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||||
const startBottomRight = point<GlobalPoint>(x2, y2);
|
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
||||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
point(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
startCenter,
|
startCenter,
|
||||||
-stateAtResizeStart.angle as Radians,
|
-stateAtResizeStart.angle as Radians,
|
||||||
);
|
);
|
||||||
@@ -419,7 +427,7 @@ const resizeSingleTextElement = (
|
|||||||
startCenter,
|
startCenter,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
const newCenter = point(
|
const newCenter = pointFrom(
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
);
|
);
|
||||||
@@ -461,13 +469,13 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft = point(x1, y1);
|
const startTopLeft = pointFrom(x1, y1);
|
||||||
const startBottomRight = point(x2, y2);
|
const startBottomRight = pointFrom(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(
|
||||||
point(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
startCenter,
|
startCenter,
|
||||||
-stateAtResizeStart.angle as Radians,
|
-stateAtResizeStart.angle as Radians,
|
||||||
);
|
);
|
||||||
@@ -648,7 +656,7 @@ export const resizeSingleElement = (
|
|||||||
startCenter,
|
startCenter,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
const newCenter = point(
|
const newCenter = pointFrom(
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
);
|
);
|
||||||
@@ -731,9 +739,9 @@ export const resizeSingleElement = (
|
|||||||
mutateElement(element, resizedElement);
|
mutateElement(element, resizedElement);
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, {
|
updateBoundElements(element, elementsMap, {
|
||||||
oldSize: {
|
newSize: {
|
||||||
width: stateAtResizeStart.width,
|
width: resizedElement.width,
|
||||||
height: stateAtResizeStart.height,
|
height: resizedElement.height,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -817,20 +825,20 @@ export const resizeMultipleElements = (
|
|||||||
const direction = transformHandleType;
|
const direction = transformHandleType;
|
||||||
|
|
||||||
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
|
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
|
||||||
ne: point(minX, maxY),
|
ne: pointFrom(minX, maxY),
|
||||||
se: point(minX, minY),
|
se: pointFrom(minX, minY),
|
||||||
sw: point(maxX, minY),
|
sw: pointFrom(maxX, minY),
|
||||||
nw: point(maxX, maxY),
|
nw: pointFrom(maxX, maxY),
|
||||||
e: point(minX, minY + height / 2),
|
e: pointFrom(minX, minY + height / 2),
|
||||||
w: point(maxX, minY + height / 2),
|
w: pointFrom(maxX, minY + height / 2),
|
||||||
n: point(minX + width / 2, maxY),
|
n: pointFrom(minX + width / 2, maxY),
|
||||||
s: point(minX + width / 2, minY),
|
s: pointFrom(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
|
||||||
? point(midX, midY)
|
? pointFrom(midX, midY)
|
||||||
: anchorsMap[direction];
|
: anchorsMap[direction];
|
||||||
|
|
||||||
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||||
@@ -991,14 +999,13 @@ export const resizeMultipleElements = (
|
|||||||
element,
|
element,
|
||||||
update: { boundTextFontSize, ...update },
|
update: { boundTextFontSize, ...update },
|
||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { angle } = update;
|
const { angle, width: newWidth, height: newHeight } = 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,
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
newSize: { width: newWidth, height: newHeight },
|
||||||
});
|
});
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
@@ -1044,8 +1051,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(
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
point(centerX, centerY),
|
pointFrom(centerX, centerY),
|
||||||
(centerAngle + origAngle - element.angle) as Radians,
|
(centerAngle + origAngle - element.angle) as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1101,40 +1108,44 @@ 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(point(x, y), point(cx, cy), -angle as Radians);
|
[x, y] = pointRotateRads(
|
||||||
|
pointFrom(x, y),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
-angle as Radians,
|
||||||
|
);
|
||||||
switch (transformHandleType) {
|
switch (transformHandleType) {
|
||||||
case "n":
|
case "n":
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x - (x1 + x2) / 2, y - y1),
|
pointFrom(x - (x1 + x2) / 2, y - y1),
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
case "s":
|
case "s":
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x - (x1 + x2) / 2, y - y2),
|
pointFrom(x - (x1 + x2) / 2, y - y2),
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
case "w":
|
case "w":
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x - x1, y - (y1 + y2) / 2),
|
pointFrom(x - x1, y - (y1 + y2) / 2),
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
case "e":
|
case "e":
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
point(x - x2, y - (y1 + y2) / 2),
|
pointFrom(x - x2, y - (y1 + y2) / 2),
|
||||||
point(0, 0),
|
pointFrom(0, 0),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
case "nw":
|
case "nw":
|
||||||
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
|
return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
|
||||||
case "ne":
|
case "ne":
|
||||||
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
|
return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
|
||||||
case "sw":
|
case "sw":
|
||||||
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
|
return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
|
||||||
case "se":
|
case "se":
|
||||||
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
|
return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
|
||||||
default:
|
default:
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { isLinearElement } from "./typeChecks";
|
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||||
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||||
import {
|
import {
|
||||||
point,
|
pointFrom,
|
||||||
pointOnLineSegment,
|
pointOnLineSegment,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
@@ -90,18 +90,26 @@ 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 = SIDE_RESIZING_THRESHOLD / zoom.value;
|
const SPACING = isImageElement(element)
|
||||||
|
? 0
|
||||||
|
: SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
|
const ZOOMED_SIDE_RESIZING_THRESHOLD =
|
||||||
|
SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
const sides = getSelectionBorders(
|
const sides = getSelectionBorders(
|
||||||
point(x1 - SPACING, y1 - SPACING),
|
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||||
point(x2 + SPACING, y2 + SPACING),
|
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||||
point(cx, cy),
|
pointFrom(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(point(x, y), side as LineSegment<Point>, SPACING)
|
pointOnLineSegment(
|
||||||
|
pointFrom(x, y),
|
||||||
|
side as LineSegment<Point>,
|
||||||
|
ZOOMED_SIDE_RESIZING_THRESHOLD,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return dir as TransformHandleType;
|
return dir as TransformHandleType;
|
||||||
}
|
}
|
||||||
@@ -178,9 +186,9 @@ export const getTransformHandleTypeFromCoords = <
|
|||||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
|
|
||||||
const sides = getSelectionBorders(
|
const sides = getSelectionBorders(
|
||||||
point(x1 - SPACING, y1 - SPACING),
|
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||||
point(x2 + SPACING, y2 + SPACING),
|
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
0 as Radians,
|
0 as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -188,7 +196,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(
|
||||||
point(scenePointerX, scenePointerY),
|
pointFrom(scenePointerX, scenePointerY),
|
||||||
side as LineSegment<Point>,
|
side as LineSegment<Point>,
|
||||||
SPACING,
|
SPACING,
|
||||||
)
|
)
|
||||||
@@ -265,10 +273,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
|||||||
center: Point,
|
center: Point,
|
||||||
angle: Radians,
|
angle: Radians,
|
||||||
) => {
|
) => {
|
||||||
const topLeft = pointRotateRads(point(x1, y1), center, angle);
|
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
|
||||||
const topRight = pointRotateRads(point(x2, y1), center, angle);
|
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
|
||||||
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
|
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
|
||||||
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
|
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
n: [topLeft, topRight],
|
n: [topLeft, topRight],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ARROW_TYPE } from "../constants";
|
import { ARROW_TYPE } from "../constants";
|
||||||
import { point } from "../../math";
|
import { pointFrom } 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(), [
|
||||||
point(-45 - arrow.x, -100.1 - arrow.y),
|
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
point(45 - arrow.x, 99.9 - arrow.y),
|
pointFrom(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: [point(0, 0), point(90, 200)],
|
points: [pointFrom(0, 0), pointFrom(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, [point(0, 0), point(90, 200)]);
|
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
import {
|
import {
|
||||||
point,
|
pointFrom,
|
||||||
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 point(p[0], bounds[1]);
|
return pointFrom(p[0], bounds[1]);
|
||||||
case HEADING_RIGHT:
|
case HEADING_RIGHT:
|
||||||
return point(bounds[2], p[1]);
|
return pointFrom(bounds[2], p[1]);
|
||||||
case HEADING_DOWN:
|
case HEADING_DOWN:
|
||||||
return point(p[0], bounds[3]);
|
return pointFrom(p[0], bounds[3]);
|
||||||
}
|
}
|
||||||
return point(bounds[0], p[1]);
|
return pointFrom(bounds[0], p[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const estimateSegmentCount = (
|
const estimateSegmentCount = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
import { 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,235 +6,10 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
wrapText,
|
|
||||||
detectLineHeight,
|
detectLineHeight,
|
||||||
getLineHeightInPx,
|
getLineHeightInPx,
|
||||||
parseTokens,
|
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
import type { ExcalidrawTextElementWithContainer } 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", () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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";
|
||||||
@@ -343,7 +344,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.
|
||||||
*/
|
*/
|
||||||
const getLineWidth = (
|
export const getLineWidth = (
|
||||||
text: string,
|
text: string,
|
||||||
font: FontString,
|
font: FontString,
|
||||||
forceAdvanceWidth?: true,
|
forceAdvanceWidth?: true,
|
||||||
@@ -408,193 +409,34 @@ 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 ascii = char.charCodeAt(0);
|
const unicode = char.charCodeAt(0);
|
||||||
if (!cachedCharWidth[font]) {
|
if (!cachedCharWidth[font]) {
|
||||||
cachedCharWidth[font] = [];
|
cachedCharWidth[font] = [];
|
||||||
}
|
}
|
||||||
if (!cachedCharWidth[font][ascii]) {
|
if (!cachedCharWidth[font][unicode]) {
|
||||||
const width = getLineWidth(char, font, true);
|
const width = getLineWidth(char, font, true);
|
||||||
cachedCharWidth[font][ascii] = width;
|
cachedCharWidth[font][unicode] = width;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedCharWidth[font][ascii];
|
return cachedCharWidth[font][unicode];
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
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円\n¥1234\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원화\n₩1234\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("#");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
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
|
||||||
|
*
|
||||||
|
* Price¥100 → ["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 { point } from "../../math";
|
import { pointFrom } 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: [point(0, 0), point(100, 0)],
|
points: [pointFrom(0, 0), pointFrom(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 \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
"Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy",
|
||||||
);
|
);
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
|
import { CLASSES, 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,11 +245,6 @@ 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;
|
||||||
@@ -259,7 +254,7 @@ export const textWysiwyg = ({
|
|||||||
lineHeight: updatedTextElement.lineHeight,
|
lineHeight: updatedTextElement.lineHeight,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
left: `${viewportX - padding}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
width,
|
width,
|
||||||
@@ -269,7 +264,6 @@ export const textWysiwyg = ({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
editorMaxHeight,
|
editorMaxHeight,
|
||||||
),
|
),
|
||||||
padding: `0 ${padding}px`,
|
|
||||||
textAlign,
|
textAlign,
|
||||||
verticalAlign,
|
verticalAlign,
|
||||||
color: updatedTextElement.strokeColor,
|
color: updatedTextElement.strokeColor,
|
||||||
@@ -310,6 +304,7 @@ 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,6 +11,7 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
|||||||
import {
|
import {
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
isIOS,
|
isIOS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
import { point, pointRotateRads } from "../../math";
|
import { pointFrom, pointRotateRads } from "../../math";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
| "n"
|
| "n"
|
||||||
@@ -95,8 +96,8 @@ const generateTransformHandle = (
|
|||||||
angle: Radians,
|
angle: Radians,
|
||||||
): TransformHandle => {
|
): TransformHandle => {
|
||||||
const [xx, yy] = pointRotateRads(
|
const [xx, yy] = pointRotateRads(
|
||||||
point(x + width / 2, y + height / 2),
|
pointFrom(x + width / 2, y + height / 2),
|
||||||
point(cx, cy),
|
pointFrom(cx, cy),
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
return [xx - width / 2, yy - height / 2, width, height];
|
return [xx - width / 2, yy - height / 2, width, height];
|
||||||
@@ -129,6 +130,7 @@ 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;
|
||||||
@@ -140,8 +142,7 @@ 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 =
|
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
|
||||||
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
|
|
||||||
|
|
||||||
const transformHandles: TransformHandles = {
|
const transformHandles: TransformHandles = {
|
||||||
nw: omitSides.nw
|
nw: omitSides.nw
|
||||||
@@ -301,8 +302,10 @@ export const getTransformHandles = (
|
|||||||
rotation: true,
|
rotation: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dashedLineMargin = isLinearElement(element)
|
const margin = 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),
|
||||||
@@ -310,7 +313,8 @@ export const getTransformHandles = (
|
|||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
omitSides,
|
omitSides,
|
||||||
dashedLineMargin,
|
margin,
|
||||||
|
isImageElement(element) ? 0 : undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ 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";
|
||||||
@@ -140,6 +149,8 @@ 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<
|
||||||
|
|||||||
@@ -36,3 +36,40 @@ 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2";
|
||||||
|
|
||||||
|
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
|
||||||
|
|
||||||
|
export const CascadiaFontFaces: ExcalidrawFontFaceDescriptor[] = [
|
||||||
|
{
|
||||||
|
uri: CascadiaCodeRegular,
|
||||||
|
},
|
||||||
|
];
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user