Compare commits

..

46 Commits

Author SHA1 Message Date
Ryan Di 913921631b fix contain dim change 2025-01-07 22:55:08 +08:00
Ryan Di ec39094933 simplify code 2025-01-07 19:23:26 +08:00
Ryan Di ef20c8b9fa tests for the updated api 2024-12-12 15:39:48 +08:00
Ryan Di 1c55160e3f debug both in app 2024-12-10 19:00:58 +08:00
Ryan Di e49db1dd3c align svg exports with canvas exports 2024-12-10 19:00:30 +08:00
Ryan Di cd8bfb06b5 fix padding and refactor 2024-12-10 18:37:53 +08:00
Ryan Di 7762b925ae debug preview fix 2024-12-05 16:02:04 +08:00
Ryan Di 113dfc0023 remove cover from fit options 2024-12-05 15:13:58 +08:00
Ryan Di 96b5cfc35d fix data param 2024-12-04 14:33:45 +08:00
Ryan Di 9da26fb7e0 unify exports in a single place 2024-12-04 14:31:15 +08:00
dwelle 392b362118 DEBUG 2024-11-26 12:12:40 +01:00
dwelle 0e02366dee wip 2024-11-26 12:12:40 +01:00
Márk Tolmács b2a6a87b10 chore: Remove @tldraw/vec (#8762)
Not needed.
2024-11-21 15:19:20 +01:00
Márk Tolmács ab8b3537b3 fix: Optimize frameToHighlight state change and snapLines state change (#8763)
Fix case when frame interactions recursively call setState() without any change.
2024-11-21 15:19:00 +01:00
Márk Tolmács d21e0008dd fix: Make some events expllicitly active to avoid console warnings (#8757)
Avoid chrome/edge reporting of by-default blocking event handlers
2024-11-21 15:18:18 +01:00
David Luzar 840f1428c4 chore: bump @excalidraw/mermaid-to-excalidraw@1.1.2 (#8830) 2024-11-20 13:10:07 +01:00
Márk Tolmács 2db5bbcb29 fix: Unify binding update options for updateBoundElements() (#8832)
Fix insonsistent naming for option newSize/oldSize for updateBoundElements()
2024-11-20 11:46:45 +01:00
David Luzar 0927431d0d chore: bump @excalidraw/mermaid-to-excalidraw (#8829) 2024-11-19 20:46:55 +01:00
dependabot[bot] 98c0a67333 build(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#8824)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 20:34:35 +01:00
Aakansha Doshi 57cf577376 fix: cleanup scripts and support upto node 22 (#8794) 2024-11-11 23:56:00 +05:30
Hamir Mahal 6e0ee89ee4 fix: usage of node12 which is deprecated (#8791) 2024-11-11 12:05:55 +01:00
David Luzar 35f778a734 build: set PWA flag in dev to false (#8788) 2024-11-09 21:04:50 +01:00
Aakansha Doshi ee091d0dbd build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment (#8784)
* build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment

* fix

* set VITE_ENABLE_PWA to false in .env.development
2024-11-09 21:45:37 +05:30
Aakansha Doshi ef9ea14a75 fix: remove manifest.json (#8783)
* fix: remove manifest.json

* disable pwa in dev
2024-11-09 13:42:11 +05:30
Aakansha Doshi df168a6883 fix: load env vars correctly and set debug and linter flags to false explicitly in prod mode (#8770)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-07 21:14:49 +00:00
David Luzar 798f5f4dfb feat: update blog url (#8767) 2024-11-06 16:41:41 +00:00
Barnabás Molnár d9ad7c039b feat: export scene to e+ on workspace creation/redemption (#8514)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-11-04 22:35:45 +00:00
Márk Tolmács 7c0239e693 fix: Console error in dev mode due to missing font path in non-prod (#8756)
Fix console error due to missing font path in dev mode reported by Firefox.
2024-11-04 17:54:00 +01:00
Aakansha Doshi da33481fa3 chore: support upto node 22.x.x (#8755) 2024-11-04 18:57:40 +05:30
Marcel Mraz 70e0e8dc29 fix: text pushes UI due to padding (#8745) 2024-11-01 23:43:48 +02:00
Marcel Mraz 2734e646ca chore: simplify line-break regexes, separate text wrapping (#8715) 2024-10-30 14:24:12 +01:00
Marcel Mraz dfaaff4432 fix: fix trailing line whitespaces layout shift (#8714) 2024-10-30 14:11:13 +01:00
Marcel Mraz 03028eaa8c fix: load font faces in Safari manually (#8693) 2024-10-30 12:40:24 +01:00
David Luzar 79b181bcdc fix: restore svg image DataURL dimensions (#8730) 2024-10-29 22:40:24 +01:00
David Luzar f9815b8b4f fix: image cropping svg + compat mode (#8710)
Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2024-10-28 10:08:05 +01:00
codeman 96ed8a4331 chore: remove duplicated meta tag (#8718)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2024-10-25 16:29:48 +00:00
Hamir Mahal 33b01d4e80 fix: usage of node12 which is deprecated (#8709) 2024-10-25 10:39:44 +05:30
Milos Vetesnik 7d52176fea feat: added sitemap & fixed robot txt (#8699) 2024-10-24 12:49:33 +02:00
David Luzar 958e03fcc6 fix: image render perf (#8697) 2024-10-23 11:58:43 +02:00
David Luzar 4cedf3d966 feat: do not strip unknown element properties on restore (#8682) 2024-10-21 22:56:22 +02:00
Ryan Di e957c8e9ee feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-10-21 22:26:52 +02:00
Denis Mishankov eb09b48ae6 fix: undo/redo action for international keyboard layouts (#8649)
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2024-10-21 17:08:39 +02:00
Marcel Mraz 61623bbeba fix: Comic Shanns issues, new fonts structure (#8641) 2024-10-21 00:11:53 +02:00
David Luzar 15ca182333 fix: remove export-to-clip-as-svg shortcut for now (#8660) 2024-10-17 20:47:05 +02:00
Marcel Mraz b479f3bd65 feat: add first-class support for CJK (#8530) 2024-10-17 20:14:17 +02:00
Mathis Beauville 21815fb930 fix: text disappearing on edit (#8558) (#8624) 2024-10-17 13:11:48 +02:00
418 changed files with 22591 additions and 7053 deletions
+12 -1
View File
@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
VITE_APP_WS_SERVER_URL=http://localhost:3002 VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=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
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v3.0.0 - uses: amannn/action-semantic-pull-request@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img <img
src={require("@site/static/img/welcome-screen-overview.png").default} src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch 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 |
+41 -29
View File
@@ -369,10 +369,12 @@ export default function ExampleApp({
return false; return false;
} }
await exportToClipboard({ await exportToClipboard({
elements: excalidrawAPI.getSceneElements(), data: {
appState: excalidrawAPI.getAppState(), elements: excalidrawAPI.getSceneElements(),
files: excalidrawAPI.getFiles(), appState: excalidrawAPI.getAppState(),
type, files: excalidrawAPI.getFiles(),
},
type: "json",
}); });
window.alert(`Copied to clipboard as ${type} successfully`); window.alert(`Copied to clipboard as ${type} successfully`);
}; };
@@ -817,15 +819,17 @@ export default function ExampleApp({
return; return;
} }
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(), data: {
appState: { elements: excalidrawAPI?.getSceneElements(),
...initialData.appState, appState: {
exportWithDarkMode, ...initialData.appState,
exportEmbedScene, exportWithDarkMode,
width: 300, exportEmbedScene,
height: 100, width: 300,
height: 100,
},
files: excalidrawAPI?.getFiles(),
}, },
files: excalidrawAPI?.getFiles(),
}); });
appRef.current.querySelector(".export-svg").innerHTML = appRef.current.querySelector(".export-svg").innerHTML =
svg.outerHTML; svg.outerHTML;
@@ -841,14 +845,18 @@ export default function ExampleApp({
return; return;
} }
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: excalidrawAPI?.getSceneElements(), data: {
mimeType: "image/png", elements: excalidrawAPI?.getSceneElements(),
appState: { appState: {
...initialData.appState, ...initialData.appState,
exportEmbedScene, exportEmbedScene,
exportWithDarkMode, exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
},
config: {
mimeType: "image/png",
}, },
files: excalidrawAPI?.getFiles(),
}); });
setBlobUrl(window.URL.createObjectURL(blob)); setBlobUrl(window.URL.createObjectURL(blob));
}} }}
@@ -864,12 +872,14 @@ export default function ExampleApp({
return; return;
} }
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(), data: {
appState: { elements: excalidrawAPI.getSceneElements(),
...initialData.appState, appState: {
exportWithDarkMode, ...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
}, },
files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont"; ctx.font = "30px Excalifont";
@@ -885,12 +895,14 @@ export default function ExampleApp({
return; return;
} }
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(), data: {
appState: { elements: excalidrawAPI.getSceneElements(),
...initialData.appState, appState: {
exportWithDarkMode, ...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
}, },
files: excalidrawAPI.getFiles(),
}); });
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont"; ctx.font = "30px Excalifont";
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
# copied assets # copied assets
public/*.woff2 public/**/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../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"
+359 -3
View File
@@ -4,7 +4,6 @@ import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState"; import { getDefaultAppState } from "../packages/excalidraw/appState";
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary"; import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
@@ -26,7 +25,13 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
StoreAction, StoreAction,
reconcileElements, reconcileElements,
exportToCanvas,
exportToSvg,
} from "../packages/excalidraw"; } from "../packages/excalidraw";
import {
exportToBlob,
getNonDeletedElements,
} from "../packages/excalidraw/index";
import type { import type {
AppState, AppState,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
@@ -127,6 +132,10 @@ import DebugCanvas, {
loadSavedDebugState, loadSavedDebugState,
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { fileSave } from "../packages/excalidraw/data/filesystem";
import { type ExportSceneConfig } from "../packages/excalidraw/scene/export";
import { round } from "../packages/math";
polyfill(); polyfill();
@@ -356,8 +365,6 @@ const ExcalidrawWrapper = () => {
const [excalidrawAPI, excalidrawRefCallback] = const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>(); useCallbackRefState<ExcalidrawImperativeAPI>();
useMathSubtype(excalidrawAPI);
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -609,6 +616,19 @@ const ExcalidrawWrapper = () => {
}; };
}, [excalidrawAPI]); }, [excalidrawAPI]);
const canvasPreviewContainerRef = useRef<HTMLDivElement>(null);
const svgPreviewContainerRef = useRef<HTMLDivElement>(null);
const [config, setConfig] = useState<ExportSceneConfig>({
scale: 1,
position: "center",
fit: "contain",
});
useEffect(() => {
localStorage.setItem("_exportConfig", JSON.stringify(config));
}, [config]);
const onChange = ( const onChange = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -618,6 +638,84 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements); collabAPI.syncElements(elements);
} }
const nonDeletedElements = getNonDeletedElements(elements);
const frame = nonDeletedElements.find(
(el) => el.strokeStyle === "dashed" && el.type === "rectangle",
);
exportToCanvas({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
// .concat(
// restoreElements(
// [
// // @ts-ignore
// {
// type: "rectangle",
// width: appState.width / zoom,
// height: appState.height / zoom,
// x: -appState.scrollX,
// y: -appState.scrollY,
// fillStyle: "solid",
// strokeColor: "transparent",
// backgroundColor: "rgba(0,0,0,0.05)",
// roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS, value: 40 },
// },
// ],
// null,
// ),
// ),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((canvas) => {
if (canvasPreviewContainerRef.current) {
canvasPreviewContainerRef.current.replaceChildren(canvas);
document.querySelector(
".canvas_dims",
)!.innerHTML = `${canvas.width}x${canvas.height} (canvas)`;
}
});
exportToSvg({
data: {
elements: nonDeletedElements.filter((x) => x.id !== frame?.id),
appState,
files,
},
config: {
...(frame
? {
...config,
width: frame.width,
height: frame.height,
x: frame.x,
y: frame.y,
}
: config),
},
}).then((svg) => {
if (svgPreviewContainerRef.current) {
svgPreviewContainerRef.current.replaceChildren(svg);
document.querySelector(".svg_dims")!.innerHTML = `${round(
parseFloat(svg.getAttribute("width") ?? ""),
0,
)}x${round(parseFloat(svg.getAttribute("height") ?? ""), 0)} (svg)`;
}
});
// this check is redundant, but since this is a hot path, it's best // this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time // not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) { if (!LocalData.isSavePaused()) {
@@ -1123,11 +1221,269 @@ const ExcalidrawWrapper = () => {
/> />
)} )}
</Excalidraw> </Excalidraw>
<div
style={{
display: "flex",
flexDirection: "column",
position: "fixed",
bottom: 60,
right: 60,
zIndex: 9999999999,
color: "black",
}}
>
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "1rem" }}>
<label>
center{" "}
<input
type="checkbox"
checked={config.position === "center"}
onChange={() =>
setConfig((s) => ({
...s,
position: s.position === "center" ? "topLeft" : "center",
}))
}
/>
</label>
<label>
fit{" "}
<select
value={config.fit}
onChange={(event) =>
setConfig((s) => ({
...s,
fit: event.target.value as any,
}))
}
>
<option value="none">none</option>
<option value="contain">contain</option>
</select>
</label>
<label>
padding{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.padding}
onChange={(event) =>
setConfig((s) => ({
...s,
padding: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
scale{" "}
<input
type="number"
max={4}
style={{ width: "3rem" }}
value={config.scale}
onChange={(event) =>
setConfig((s) => ({
...s,
scale: !event.target.value.trim()
? undefined
: Math.min(parseFloat(event.target.value as any), 4),
}))
}
/>
</label>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
width{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.width}
onChange={(event) =>
setConfig((s) => ({
...s,
width: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label
style={{
opacity:
config.maxWidthOrHeight != null ||
config.widthOrHeight != null
? 0.5
: undefined,
}}
>
height{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.height}
onChange={(event) =>
setConfig((s) => ({
...s,
height: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
<label>
x{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.x}
onChange={(event) =>
setConfig((s) => ({
...s,
x: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label>
y{" "}
<input
type="number"
style={{ width: "3rem" }}
value={config.y}
onChange={(event) =>
setConfig((s) => ({
...s,
y: !event.target.value.trim()
? undefined
: parseFloat(event.target.value as any) ?? undefined,
}))
}
/>
</label>
<label
style={{
opacity: config.widthOrHeight != null ? 0.5 : undefined,
}}
>
maxWH{" "}
<input
type="number"
// max={600}
style={{ width: "3rem" }}
value={config.maxWidthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
maxWidthOrHeight: !event.target.value.trim()
? undefined
: parseInt(event.target.value as any),
}))
}
/>
</label>
<label>
widthOrHeight{" "}
<input
type="number"
max={600}
style={{ width: "3rem" }}
value={config.widthOrHeight}
onChange={(event) =>
setConfig((s) => ({
...s,
widthOrHeight: !event.target.value.trim()
? undefined
: Math.min(parseInt(event.target.value as any), 600),
}))
}
/>
</label>
</div>
</div>
<div className="canvas_dims">0x0</div>
<div
ref={canvasPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
<div className="svg_dims">0x0</div>
<div
ref={svgPreviewContainerRef}
onClick={() => {
exportToBlob({
data: {
elements: excalidrawAPI!.getSceneElements(),
files: excalidrawAPI?.getFiles() || null,
},
config,
}).then((blob) => {
fileSave(blob, {
name: "xx",
extension: "png",
description: "xxx",
});
});
}}
style={{
borderRadius: 12,
border: "1px solid #777",
overflow: "hidden",
padding: 10,
backgroundColor: "pink",
}}
/>
</div>
</div> </div>
); );
}; };
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
const isCloudExportWindow =
window.location.pathname === "/excalidraw-plus-export";
if (isCloudExportWindow) {
return <ExcalidrawPlusIframeExport />;
}
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}> <Provider unstable_createStore={() => appJotaiStore}>
@@ -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;
};
+27 -2
View File
@@ -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" ||
+12 -8
View File
@@ -21,15 +21,19 @@ export const AIComponents = ({
const appState = excalidrawAPI.getAppState(); const appState = excalidrawAPI.getAppState();
const blob = await exportToBlob({ const blob = await exportToBlob({
elements: children, data: {
appState: { elements: children,
...appState, appState: {
exportBackground: true, ...appState,
viewBackgroundColor: appState.viewBackgroundColor, exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
files: excalidrawAPI.getFiles(),
},
config: {
exportingFrame: frame,
mimeType: MIME_TYPES.jpg,
}, },
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
}); });
const dataURL = await getDataURL(blob); const dataURL = await getDataURL(blob);
+1 -1
View File
@@ -84,7 +84,7 @@ const _debugRenderer = (
scale, scale,
normalizedWidth, normalizedWidth,
normalizedHeight, normalizedHeight,
viewBackgroundColor: "transparent", canvasBackgroundColor: "transparent",
}); });
// Apply zoom // Apply zoom
+50 -21
View File
@@ -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();
} }
} }
+4 -4
View File
@@ -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);
} }
}), }),
); );
+4 -4
View File
@@ -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);
} }
}), }),
); );
+9 -13
View File
@@ -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,19 +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>
<% } %> <% } %>
<!-- 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" />
@@ -218,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
@@ -250,5 +245,6 @@
} }
</script> </script>
<!-- end LEGACY GOOGLE ANALYTICS --> <!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body> </body>
</html> </html>
+10 -7
View File
@@ -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"
} }
} }
+3
View File
@@ -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 -205
View File
@@ -5,217 +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
// don't auto-inline small assets (i.e. fonts hosted on CDN) //more located in parallel with the vite.config.ts file but in parent dir
assetsInlineLimit: 0, envDir: "../",
}, build: {
plugins: [ outDir: "build",
woff2BrowserPlugin(), rollupOptions: {
react(), output: {
checker({ assetFileNames(chunkInfo) {
typescript: true, if (chunkInfo?.name?.endsWith(".woff2")) {
eslint: const family = chunkInfo.name.split("-")[0];
envVars.VITE_APP_ENABLE_ESLINT === "false" return `fonts/${family}/[name][extname]`;
? undefined }
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
/* set this flag to true to enable in Development mode */
enabled: false,
},
workbox: { 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 -13
View File
@@ -32,9 +32,7 @@
"husky": "7.0.4", "husky": "7.0.4",
"jsdom": "22.1.0", "jsdom": "22.1.0",
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
"patch-package": "8.0.0",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"postinstall-postinstall": "2.1.0",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
@@ -47,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",
@@ -57,16 +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",
"postinstall": "patch-package",
"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 .",
@@ -77,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",
+2 -1
View File
@@ -15,6 +15,8 @@ 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) - 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)
@@ -305,7 +307,6 @@ define: {
## 0.16.0 (2023-09-19) ## 0.16.0 (2023-09-19)
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
@@ -10,7 +10,7 @@ import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureTextElement, measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@@ -31,7 +31,7 @@ import type {
} from "../element/types"; } from "../element/types";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { arrayToMap } from "../utils"; import { arrayToMap, getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex"; import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -51,9 +51,11 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
const { width, height } = measureTextElement(boundTextElement, { const { width, height } = measureText(
text: boundTextElement.originalText, boundTextElement.originalText,
}); getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
+35 -13
View File
@@ -9,8 +9,9 @@ import {
readSystemClipboard, readSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas, prepareElementsForExport } from "../data/index"; import { exportAsImage } from "../data/index";
import { getTextFromElements, isTextElement } from "../element"; import { getTextFromElements, isTextElement } from "../element";
import { prepareElementsForExport } from "../data/index";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
@@ -136,25 +137,41 @@ export const actionCopyAsSvg = register({
); );
try { try {
await exportCanvas( await exportAsImage({
"clipboard-svg", type: "clipboard-svg",
exportedElements, data: { elements: exportedElements, appState, files: app.files },
appState, config: {
app.files,
{
...appState, ...appState,
exportingFrame, exportingFrame,
name: app.getName(), name: app.getName(),
}, },
); });
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return { return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -190,11 +207,16 @@ export const actionCopyAsPng = register({
true, true,
); );
try { try {
await exportCanvas("clipboard", exportedElements, appState, app.files, { await exportAsImage({
...appState, type: "clipboard",
exportingFrame, data: { elements: exportedElements, appState, files: app.files },
name: app.getName(), config: {
...appState,
exportingFrame,
name: appState.name || app.getName(),
},
}); });
return { return {
appState: { appState: {
...appState, ...appState,
@@ -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)}
/>
);
},
});
+14 -2
View File
@@ -10,13 +10,13 @@ import { useDevice } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { getExportSize } from "../scene/export"; import { getCanvasSize } from "../scene/export";
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants"; import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import type { Theme } from "../element/types"; import type { NonDeletedExcalidrawElement, Theme } from "../element/types";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -58,6 +58,18 @@ export const actionChangeExportScale = register({
? getSelectedElements(elements, appState) ? getSelectedElements(elements, appState)
: elements; : elements;
const getExportSize = (
elements: readonly NonDeletedExcalidrawElement[],
padding: number,
scale: number,
): [number, number] => {
const [, , width, height] = getCanvasSize(elements).map((dimension) =>
Math.trunc(dimension * scale),
);
return [width + padding * 2, height + padding * 2];
};
return ( return (
<> <>
{EXPORT_SCALES.map((s) => { {EXPORT_SCALES.map((s) => {
@@ -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,
+2
View File
@@ -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";
+8 -60
View File
@@ -6,7 +6,6 @@ import type {
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource, ActionSource,
ActionPredicateFn,
} from "./types"; } from "./types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -46,7 +45,6 @@ const trackAction = (
export class ActionManager { export class ActionManager {
actions = {} as Record<ActionName, Action>; actions = {} as Record<ActionName, Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -74,37 +72,6 @@ export class ActionManager {
this.app = app; this.app = app;
} }
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
filterActions(
filter: ActionPredicateFn,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
},
): Action[] {
// For testing
if (this === undefined) {
return [];
}
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
const actions: Action[] = [];
for (const key in this.actions) {
const action = this.actions[key as ActionName];
if (filter(action, elements, appState, this.app, data)) {
actions.push(action);
}
}
return actions;
}
registerAction(action: Action) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
@@ -121,7 +88,7 @@ export class ActionManager {
(action) => (action) =>
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: this.isActionEnabled(action, { noPredicates: true })) && : true) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -180,7 +147,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
(name in canvasActions (name in canvasActions
? canvasActions[name as keyof typeof canvasActions] ? canvasActions[name as keyof typeof canvasActions]
: this.isActionEnabled(this.actions[name], { noPredicates: true })) : true)
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -202,7 +169,6 @@ export class ActionManager {
return ( return (
<PanelComponent <PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()} elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()} appState={this.getAppState()}
updateData={updateData} updateData={updateData}
@@ -216,31 +182,13 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = ( isActionEnabled = (action: Action) => {
action: Action, const elements = this.getElementsIncludingDeleted();
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const data = opts?.data;
if ( return (
!opts?.noPredicates && !action.predicate ||
action.predicate && action.predicate(elements, appState, this.app.props, this.app)
!action.predicate(elements, appState, this.app.props, this.app, data) );
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, this.app, data)) {
enabled = false;
}
});
return enabled;
}; };
} }
+1 -13
View File
@@ -2,12 +2,11 @@ import { isDarwin } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import type { SubtypeOf } from "../utility-types"; import type { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import type { ActionName, CustomActionName } from "./types"; import type { ActionName } from "./types";
export type ShortcutName = export type ShortcutName =
| SubtypeOf< | SubtypeOf<
ActionName, ActionName,
| CustomActionName
| "toggleTheme" | "toggleTheme"
| "loadScene" | "loadScene"
| "clearCanvas" | "clearCanvas"
@@ -24,7 +23,6 @@ export type ShortcutName =
| "sendToBack" | "sendToBack"
| "bringToFront" | "bringToFront"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg"
| "group" | "group"
| "ungroup" | "ungroup"
| "gridMode" | "gridMode"
@@ -55,15 +53,6 @@ export type ShortcutName =
| "commandPalette" | "commandPalette"
| "searchMenu"; | "searchMenu";
export const registerCustomShortcuts = (
shortcuts: Record<CustomActionName, string[]>,
) => {
for (const key in shortcuts) {
const shortcut = key as CustomActionName;
shortcutMap[shortcut] = shortcuts[shortcut];
}
};
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
saveScene: [getShortcutKey("CtrlOrCmd+S")], saveScene: [getShortcutKey("CtrlOrCmd+S")],
@@ -98,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+'")],
+3 -21
View File
@@ -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;
@@ -41,24 +37,10 @@ type ActionFn = (
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export const makeCustomActionName = (name: string) =>
`custom.${name}` as CustomActionName;
export type CustomActionName = `custom.${string}`;
export type ActionName = export type ActionName =
| CustomActionName
| "copy" | "copy"
| "cut" | "cut"
| "paste" | "paste"
@@ -152,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[];
@@ -193,7 +176,6 @@ export interface Action {
appState: AppState, appState: AppState,
appProps: ExcalidrawProps, appProps: ExcalidrawProps,
app: AppClassProperties, app: AppClassProperties,
data?: Record<string, any>,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
+9 -6
View File
@@ -1,17 +1,18 @@
import { COLOR_PALETTE } from "./colors";
import { import {
ARROW_TYPE, ARROW_TYPE,
COLOR_WHITE,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
DEFAULT_ZOOM_VALUE,
EXPORT_SCALES, EXPORT_SCALES,
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
} from "./constants"; } from "./constants";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio ? devicePixelRatio
@@ -99,10 +100,10 @@ export const getDefaultAppState = (): Omit<
editingFrame: null, editingFrame: null,
elementsToHighlight: null, elementsToHighlight: null,
toast: null, toast: null,
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_WHITE,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { zoom: {
value: 1 as NormalizedZoomValue, value: DEFAULT_ZOOM_VALUE,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null, pendingImageElementId: null,
@@ -116,6 +117,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: [],
}; };
}; };
@@ -170,8 +173,6 @@ const APP_STATE_STORAGE_CONF = (<
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
activeSubtypes: { browser: true, export: false, server: false },
customData: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
@@ -239,6 +240,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 },
}); });
+32 -1
View File
@@ -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;
+5 -28
View File
@@ -1,11 +1,8 @@
import type { Radians } from "../math"; import type { Radians } from "../math";
import { pointFrom } from "../math"; import { pointFrom } from "../math";
import { DEFAULT_CHART_COLOR_INDEX, getAllColorsSpecificShade } from "./colors";
import { import {
COLOR_PALETTE, COLOR_CHARCOAL_BLACK,
DEFAULT_CHART_COLOR_INDEX,
getAllColorsSpecificShade,
} from "./colors";
import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
VERTICAL_ALIGN, VERTICAL_ALIGN,
@@ -13,8 +10,6 @@ import {
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import type { NonDeletedExcalidrawElement } from "./element/types"; import type { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
import type { AppState } from "./types";
import { selectSubtype } from "./element/subtypes";
export type ChartElements = readonly NonDeletedExcalidrawElement[]; export type ChartElements = readonly NonDeletedExcalidrawElement[];
@@ -27,8 +22,6 @@ export interface Spreadsheet {
title: string | null; title: string | null;
labels: string[] | null; labels: string[] | null;
values: number[]; values: number[];
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
} }
export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
@@ -177,7 +170,7 @@ const commonProps = {
fontSize: DEFAULT_FONT_SIZE, fontSize: DEFAULT_FONT_SIZE,
opacity: 100, opacity: 100,
roughness: 1, roughness: 1,
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_CHARCOAL_BLACK,
roundness: null, roundness: null,
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
@@ -199,17 +192,13 @@ const chartXLabels = (
groupId: string, groupId: string,
backgroundColor: string, backgroundColor: string,
): ChartElements => { ): ChartElements => {
const custom = selectSubtype(spreadsheet, "text");
return ( return (
spreadsheet.labels?.map((label, index) => { spreadsheet.labels?.map((label, index) => {
return newTextElement({ return newTextElement({
groupIds: [groupId], groupIds: [groupId],
backgroundColor, backgroundColor,
...commonProps, ...commonProps,
text: text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
label.length > 8 && custom.subtype === undefined
? `${label.slice(0, 5)}...`
: label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
@@ -217,7 +206,6 @@ const chartXLabels = (
fontSize: 16, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
...custom,
}); });
}) || [] }) || []
); );
@@ -238,7 +226,6 @@ const chartYLabels = (
y: y - BAR_GAP, y: y - BAR_GAP,
text: "0", text: "0",
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
const maxYLabel = newTextElement({ const maxYLabel = newTextElement({
@@ -249,7 +236,6 @@ const chartYLabels = (
y: y - BAR_HEIGHT - minYLabel.height / 2, y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(), text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right", textAlign: "right",
...selectSubtype(spreadsheet, "text"),
}); });
return [minYLabel, maxYLabel]; return [minYLabel, maxYLabel];
@@ -272,7 +258,6 @@ const chartLines = (
y, y,
width: chartWidth, width: chartWidth,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
...selectSubtype(spreadsheet, "line"),
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -284,7 +269,6 @@ const chartLines = (
y, y,
height: chartHeight, height: chartHeight,
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
...selectSubtype(spreadsheet, "line"),
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -298,7 +282,6 @@ const chartLines = (
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
...selectSubtype(spreadsheet, "line"),
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -325,7 +308,6 @@ const chartBaseElements = (
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null, roundness: null,
textAlign: "center", textAlign: "center",
...selectSubtype(spreadsheet, "text"),
}) })
: null; : null;
@@ -339,10 +321,9 @@ const chartBaseElements = (
y: y - chartHeight, y: y - chartHeight,
width: chartWidth, width: chartWidth,
height: chartHeight, height: chartHeight,
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_CHARCOAL_BLACK,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
...selectSubtype(spreadsheet, "rectangle"),
}) })
: null; : null;
@@ -375,7 +356,6 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
...selectSubtype(spreadsheet, "rectangle"),
}); });
}); });
@@ -426,7 +406,6 @@ const chartTypeLine = (
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
points: points as any, points: points as any,
...selectSubtype(spreadsheet, "line"),
}); });
const dots = spreadsheet.values.map((value, index) => { const dots = spreadsheet.values.map((value, index) => {
@@ -443,7 +422,6 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2, y: y + cy - BAR_GAP * 2,
width: BAR_GAP, width: BAR_GAP,
height: BAR_GAP, height: BAR_GAP,
...selectSubtype(spreadsheet, "ellipse"),
}); });
}); });
@@ -461,7 +439,6 @@ const chartTypeLine = (
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [pointFrom(0, 0), pointFrom(0, cy)], points: [pointFrom(0, 0), pointFrom(0, cy)],
...selectSubtype(spreadsheet, "line"),
}); });
}); });
+1 -6
View File
@@ -2,7 +2,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import type { AppState, BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import { import {
@@ -333,7 +333,6 @@ const parseClipboardEvent = async (
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
appState?: AppState,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEvent(event, isPlainPaste); const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
@@ -350,10 +349,6 @@ export const parseClipboard = async (
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
if (spreadsheetResult) { if (spreadsheetResult) {
if ("spreadsheet" in spreadsheetResult) {
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
spreadsheetResult.spreadsheet.customData = appState?.customData;
}
return spreadsheetResult; return spreadsheetResult;
} }
} catch (error: any) { } catch (error: any) {
+21 -23
View File
@@ -1,27 +1,25 @@
import oc from "open-color"; import oc from "open-color";
import type { Merge } from "./utility-types"; import {
COLOR_WHITE,
// FIXME can't put to utils.ts rn because of circular dependency COLOR_CHARCOAL_BLACK,
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( COLOR_TRANSPARENT,
source: R, } from "./constants";
keys: K, import { type Merge } from "./utility-types";
) => { import { pick } from "./utils";
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
export type ColorPickerColor = export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime"> | Exclude<keyof oc, "indigo" | "lime" | "black">
| "transparent" | "transparent"
| "charcoal"
| "bronze"; | "bronze";
export type ColorTuple = readonly [string, string, string, string, string]; export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge< export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>, Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" } {
charcoal: typeof COLOR_CHARCOAL_BLACK;
white: typeof COLOR_WHITE;
transparent: typeof COLOR_TRANSPARENT;
}
>; >;
// used general type instead of specific type (ColorPalette) to support custom colors // used general type instead of specific type (ColorPalette) to support custom colors
@@ -41,7 +39,7 @@ export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = ( export const getSpecificColorShades = (
color: Exclude< color: Exclude<
ColorPickerColor, ColorPickerColor,
"transparent" | "white" | "black" | "bronze" "transparent" | "charcoal" | "black" | "white" | "bronze"
>, >,
indexArr: Readonly<ColorShadesIndexes>, indexArr: Readonly<ColorShadesIndexes>,
) => { ) => {
@@ -49,9 +47,9 @@ export const getSpecificColorShades = (
}; };
export const COLOR_PALETTE = { export const COLOR_PALETTE = {
transparent: "transparent", transparent: COLOR_TRANSPARENT,
black: "#1e1e1e", charcoal: COLOR_CHARCOAL_BLACK,
white: "#ffffff", white: COLOR_WHITE,
// open-colors // open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES), gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES), red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
@@ -87,7 +85,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
// ORDER matters for positioning in quick picker // ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [ export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.black, COLOR_PALETTE.charcoal,
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX], COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
@@ -125,7 +123,7 @@ export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent, transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white, white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray, gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black, charcoal: COLOR_PALETTE.charcoal,
bronze: COLOR_PALETTE.bronze, bronze: COLOR_PALETTE.bronze,
// rest // rest
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
@@ -136,7 +134,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
transparent: COLOR_PALETTE.transparent, transparent: COLOR_PALETTE.transparent,
white: COLOR_PALETTE.white, white: COLOR_PALETTE.white,
gray: COLOR_PALETTE.gray, gray: COLOR_PALETTE.gray,
black: COLOR_PALETTE.black, charcoal: COLOR_PALETTE.charcoal,
bronze: COLOR_PALETTE.bronze, bronze: COLOR_PALETTE.bronze,
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
+7 -2
View File
@@ -21,12 +21,12 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions } from "./Subtypes";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow, isElbowArrow,
isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -128,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>
@@ -137,7 +142,6 @@ export const SelectedShapeActions = ({
{canChangeBackgroundColor(appState, targetElements) && ( {canChangeBackgroundColor(appState, targetElements) && (
<div>{renderAction("changeBackgroundColor")}</div> <div>{renderAction("changeBackgroundColor")}</div>
)} )}
<SubtypeShapeActions elements={targetElements} />
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) || {(hasStrokeWidth(appState.activeTool.type) ||
@@ -247,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>
+431 -164
View File
@@ -35,6 +35,7 @@ import {
actionToggleElementLock, actionToggleElementLock,
actionToggleLinearEditor, actionToggleLinearEditor,
actionToggleObjectsSnapMode, actionToggleObjectsSnapMode,
actionToggleCropEditor,
} from "../actions"; } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
@@ -48,7 +49,7 @@ import {
} from "../appState"; } from "../appState";
import type { PastedMixedContent } from "../clipboard"; import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants"; import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants";
import { import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
@@ -89,7 +90,7 @@ import {
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data"; import { exportAsImage, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restore, restoreElements } from "../data/restore";
import { import {
@@ -301,22 +302,12 @@ import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import type {
SubtypeLoadedCb,
SubtypeRecord,
SubtypePrepFn,
} from "../element/subtypes";
import {
checkRefreshOnSubtypeLoad,
isSubtypeAction,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../element/subtypes";
import { import {
dataURLToFile, dataURLToFile,
dataURLToString,
generateIdFromFile, generateIdFromFile,
getDataURL, getDataURL,
getDataURL_sync,
getFileFromEvent, getFileFromEvent,
ImageURLToFile, ImageURLToFile,
isImageFileHandle, isImageFileHandle,
@@ -349,7 +340,6 @@ import {
isValidTextContainer, isValidTextContainer,
measureText, measureText,
normalizeText, normalizeText,
wrapText,
} from "../element/textElement"; } from "../element/textElement";
import { import {
showHyperlinkTooltip, showHyperlinkTooltip,
@@ -457,7 +447,20 @@ import {
} from "../element/flowchart"; } from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu"; import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { pointFrom, pointDistance, vector } from "../../math"; import {
clamp,
pointFrom,
pointDistance,
vector,
pointRotateRads,
vectorScale,
vectorFromPoint,
vectorSubtract,
vectorDot,
vectorNormalize,
} from "../../math";
import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -601,6 +604,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
lastPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); animationFrameHandler = new AnimationFrameHandler();
@@ -722,7 +726,6 @@ class App extends React.Component<AppProps, AppState> {
registerAction: (action: Action) => { registerAction: (action: Action) => {
this.actionManager.registerAction(action); this.actionManager.registerAction(action);
}, },
addSubtype: this.addSubtype,
refresh: this.refresh, refresh: this.refresh,
setToast: this.setToast, setToast: this.setToast,
id: this.id, id: this.id,
@@ -749,7 +752,7 @@ class App extends React.Component<AppProps, AppState> {
id: this.id, id: this.id,
}; };
this.fonts = new Fonts({ scene: this.scene }); this.fonts = new Fonts(this.scene);
this.history = new History(); this.history = new History();
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
@@ -759,19 +762,6 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction( this.actionManager.registerAction(
createRedoAction(this.history, this.store), createRedoAction(this.history, this.store),
); );
this.actionManager.registerActionPredicate(subtypeActionPredicate);
}
private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) {
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
const elements = this.getSceneElementsIncludingDeleted();
// If there are any elements of the just-registered subtype,
// refresh the scene to re-render each such element.
if (checkRefreshOnSubtypeLoad(hasSubtype, elements)) {
this.refresh();
}
};
return prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
} }
private onWindowMessage(event: MessageEvent) { private onWindowMessage(event: MessageEvent) {
@@ -1825,18 +1815,20 @@ class App extends React.Component<AppProps, AppState> {
opts: { exportingFrame: ExcalidrawFrameLikeElement | null }, opts: { exportingFrame: ExcalidrawFrameLikeElement | null },
) => { ) => {
trackEvent("export", type, "ui"); trackEvent("export", type, "ui");
const fileHandle = await exportCanvas( const fileHandle = await exportAsImage({
type, type,
elements, data: {
this.state, elements,
this.files, appState: this.state,
{ files: this.files,
},
config: {
exportBackground: this.state.exportBackground, exportBackground: this.state.exportBackground,
name: this.getName(), name: this.getName(),
viewBackgroundColor: this.state.viewBackgroundColor, viewBackgroundColor: this.state.viewBackgroundColor,
exportingFrame: opts.exportingFrame, exportingFrame: opts.exportingFrame,
}, },
) })
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
@@ -2134,9 +2126,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (actionResult.files) { if (actionResult.files) {
this.files = actionResult.replaceFiles this.addMissingFiles(actionResult.files, actionResult.replaceFiles);
? actionResult.files
: { ...this.files, ...actionResult.files };
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
} }
@@ -2176,11 +2166,12 @@ class App extends React.Component<AppProps, AppState> {
editingTextElement = null; editingTextElement = null;
} }
this.setState((state) => { this.setState((prevAppState) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into const actionAppState = actionResult.appState || {};
// regarding the resulting type as not containing undefined
// (which the following expression will never contain) return {
return Object.assign(actionResult.appState || {}, { ...prevAppState,
...actionAppState,
// NOTE this will prevent opening context menu using an action // NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be // or programmatically from the host, so it will need to be
// rewritten later // rewritten later
@@ -2191,7 +2182,7 @@ class App extends React.Component<AppProps, AppState> {
theme, theme,
name, name,
errorMessage, errorMessage,
}); };
}); });
didUpdate = true; didUpdate = true;
@@ -2331,11 +2322,11 @@ class App extends React.Component<AppProps, AppState> {
// clear the shape and image cache so that any images in initialData // clear the shape and image cache so that any images in initialData
// can be loaded fresh // can be loaded fresh
this.clearImageShapeCache(); this.clearImageShapeCache();
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all // manually loading the font faces seems faster even in browsers that do fire the loadingdone event
// fonts and rerender scene text elements once done. This also this.fonts.loadSceneFonts().then((fontFaces) => {
// seems faster even in browsers that do fire the loadingdone event. this.fonts.onLoaded(fontFaces);
this.fonts.loadSceneFonts(); });
}; };
private isMobileBreakpoint = (width: number, height: number) => { private isMobileBreakpoint = (width: number, height: number) => {
@@ -2496,7 +2487,7 @@ class App extends React.Component<AppProps, AppState> {
this.renderer.destroy(); this.renderer.destroy();
this.scene.destroy(); this.scene.destroy();
this.scene = new Scene(); this.scene = new Scene();
this.fonts = new Fonts({ scene: this.scene }); this.fonts = new Fonts(this.scene);
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
this.files = {}; this.files = {};
this.imageCache.clear(); this.imageCache.clear();
@@ -2568,19 +2559,27 @@ class App extends React.Component<AppProps, AppState> {
{ passive: false }, { passive: false },
), ),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553 addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
addEventListener(document, EVENT.COPY, this.onCopy), passive: false,
}), // #3553
addEventListener(document, EVENT.COPY, this.onCopy, { passive: false }),
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }), addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
addEventListener( addEventListener(
document, document,
EVENT.POINTER_MOVE, EVENT.POINTER_MOVE,
this.updateCurrentCursorPosition, this.updateCurrentCursorPosition,
{ passive: false },
), ),
// rerender text elements on font load to fix #637 && #1553 // rerender text elements on font load to fix #637 && #1553
addEventListener(document.fonts, "loadingdone", (event) => { addEventListener(
const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; document.fonts,
this.fonts.onLoaded(loadedFontFaces); "loadingdone",
}), (event) => {
const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
this.fonts.onLoaded(fontFaces);
},
{ passive: false },
),
// Safari-only desktop pinch zoom // Safari-only desktop pinch zoom
addEventListener( addEventListener(
document, document,
@@ -2600,12 +2599,17 @@ class App extends React.Component<AppProps, AppState> {
this.onGestureEnd as any, this.onGestureEnd as any,
false, false,
), ),
addEventListener(window, EVENT.FOCUS, () => { addEventListener(
this.maybeCleanupAfterMissingPointerUp(null); window,
// browsers (chrome?) tend to free up memory a lot, which results EVENT.FOCUS,
// in canvas context being cleared. Thus re-render on focus. () => {
this.triggerRender(true); this.maybeCleanupAfterMissingPointerUp(null);
}), // browsers (chrome?) tend to free up memory a lot, which results
// in canvas context being cleared. Thus re-render on focus.
this.triggerRender(true);
},
{ passive: false },
),
); );
if (this.state.viewModeEnabled) { if (this.state.viewModeEnabled) {
@@ -2621,9 +2625,12 @@ class App extends React.Component<AppProps, AppState> {
document, document,
EVENT.FULLSCREENCHANGE, EVENT.FULLSCREENCHANGE,
this.onFullscreenChange, this.onFullscreenChange,
{ passive: false },
), ),
addEventListener(document, EVENT.PASTE, this.pasteFromClipboard), addEventListener(document, EVENT.PASTE, this.pasteFromClipboard, {
addEventListener(document, EVENT.CUT, this.onCut), passive: false,
}),
addEventListener(document, EVENT.CUT, this.onCut, { passive: false }),
addEventListener(window, EVENT.RESIZE, this.onResize, false), addEventListener(window, EVENT.RESIZE, this.onResize, false),
addEventListener(window, EVENT.UNLOAD, this.onUnload, false), addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
addEventListener(window, EVENT.BLUR, this.onBlur, false), addEventListener(window, EVENT.BLUR, this.onBlur, false),
@@ -2631,6 +2638,7 @@ class App extends React.Component<AppProps, AppState> {
this.excalidrawContainerRef.current, this.excalidrawContainerRef.current,
EVENT.WHEEL, EVENT.WHEEL,
this.handleWheel, this.handleWheel,
{ passive: false },
), ),
addEventListener( addEventListener(
this.excalidrawContainerRef.current, this.excalidrawContainerRef.current,
@@ -2652,6 +2660,7 @@ class App extends React.Component<AppProps, AppState> {
getNearestScrollableContainer(this.excalidrawContainerRef.current!), getNearestScrollableContainer(this.excalidrawContainerRef.current!),
EVENT.SCROLL, EVENT.SCROLL,
this.onScroll, this.onScroll,
{ passive: false },
), ),
); );
} }
@@ -2977,7 +2986,7 @@ class App extends React.Component<AppProps, AppState> {
// event else some browsers (FF...) will clear the clipboardData // event else some browsers (FF...) will clear the clipboardData
// (something something security) // (something something security)
let file = event?.clipboardData?.files[0]; let file = event?.clipboardData?.files[0];
const data = await parseClipboard(event, isPlainPaste, this.state); const data = await parseClipboard(event, isPlainPaste);
if (!file && !isPlainPaste) { if (!file && !isPlainPaste) {
if (data.mixedContent) { if (data.mixedContent) {
return this.addElementsFromMixedContentPaste(data.mixedContent, { return this.addElementsFromMixedContentPaste(data.mixedContent, {
@@ -3247,8 +3256,15 @@ class App extends React.Component<AppProps, AppState> {
} }
}); });
// paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
if (isSafari) {
Fonts.loadElementsFonts(newElements).then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
}
if (opts.files) { if (opts.files) {
this.files = { ...this.files, ...opts.files }; this.addMissingFiles(opts.files);
} }
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
@@ -3415,7 +3431,6 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
textAlign: DEFAULT_TEXT_ALIGN, textAlign: DEFAULT_TEXT_ALIGN,
verticalAlign: DEFAULT_VERTICAL_ALIGN, verticalAlign: DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
locked: false, locked: false,
}; };
const fontString = getFontString({ const fontString = getFontString({
@@ -3758,23 +3773,56 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
/** adds supplied files to existing files in the appState */ /**
* adds supplied files to existing files in the appState.
* NOTE if file already exists in editor state, the file data is not updated
* */
public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates( public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
(files) => { (files) => {
const filesMap = files.reduce((acc, fileData) => { const { addedFiles } = this.addMissingFiles(files);
acc.set(fileData.id, fileData);
return acc;
}, new Map<FileId, BinaryFileData>());
this.files = { ...this.files, ...Object.fromEntries(filesMap) }; this.clearImageShapeCache(addedFiles);
this.clearImageShapeCache(Object.fromEntries(filesMap));
this.scene.triggerUpdate(); this.scene.triggerUpdate();
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
}, },
); );
private addMissingFiles = (
files: BinaryFiles | BinaryFileData[],
replace = false,
) => {
const nextFiles = replace ? {} : { ...this.files };
const addedFiles: BinaryFiles = {};
const _files = Array.isArray(files) ? files : Object.values(files);
for (const fileData of _files) {
if (nextFiles[fileData.id]) {
continue;
}
addedFiles[fileData.id] = fileData;
nextFiles[fileData.id] = fileData;
if (fileData.mimeType === MIME_TYPES.svg) {
const restoredDataURL = getDataURL_sync(
normalizeSVG(dataURLToString(fileData.dataURL)),
MIME_TYPES.svg,
);
if (fileData.dataURL !== restoredDataURL) {
// bump version so persistence layer can update the store
fileData.version = (fileData.version ?? 1) + 1;
fileData.dataURL = restoredDataURL;
}
}
}
this.files = nextFiles;
return { addedFiles };
};
public updateScene = withBatchedUpdates( public updateScene = withBatchedUpdates(
<K extends keyof AppState>(sceneData: { <K extends keyof AppState>(sceneData: {
elements?: SceneData["elements"]; elements?: SceneData["elements"];
@@ -3950,6 +3998,28 @@ class App extends React.Component<AppProps, AppState> {
} }
if (!isInputLike(event.target)) { if (!isInputLike(event.target)) {
if (
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
this.state.croppingElementId
) {
this.finishImageCropping();
return;
}
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElementsMap(),
this.state,
);
if (
selectedElements.length === 1 &&
isImageElement(selectedElements[0]) &&
event.key === KEYS.ENTER
) {
this.startImageCropping(selectedElements[0]);
return;
}
if ( if (
event.key === KEYS.ESCAPE && event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart this.flowChartCreator.isCreatingChart
@@ -4764,10 +4834,13 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
} }
this.setState({ flushSync(() => {
newElement: null, this.setState({
editingTextElement: null, newElement: null,
editingTextElement: null,
});
}); });
if (this.state.activeTool.locked) { if (this.state.activeTool.locked) {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, this.state);
} }
@@ -4934,7 +5007,7 @@ class App extends React.Component<AppProps, AppState> {
const selectionShape = getSelectionBoxShape( const selectionShape = getSelectionBoxShape(
element, element,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.getElementHitThreshold(), isImageElement(element) ? 0 : this.getElementHitThreshold(),
); );
return isPointInShape(pointFrom(x, y), selectionShape); return isPointInShape(pointFrom(x, y), selectionShape);
@@ -5125,7 +5198,6 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition verticalAlign: parentCenterPosition
? VERTICAL_ALIGN.MIDDLE ? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
...selectSubtype(this.state, "text"),
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
lineHeight, lineHeight,
@@ -5164,6 +5236,22 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private startImageCropping = (image: ExcalidrawImageElement) => {
this.store.shouldCaptureIncrement();
this.setState({
croppingElementId: image.id,
});
};
private finishImageCropping = () => {
if (this.state.croppingElementId) {
this.store.shouldCaptureIncrement();
this.setState({
croppingElementId: null,
});
}
};
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>, event: React.MouseEvent<HTMLCanvasElement>,
) => { ) => {
@@ -5195,6 +5283,11 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
this.startImageCropping(selectedElements[0]);
return;
}
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
@@ -6764,11 +6857,24 @@ class App extends React.Component<AppProps, AppState> {
this.device, this.device,
); );
if (elementWithTransformHandleType != null) { if (elementWithTransformHandleType != null) {
this.setState({ if (
resizingElement: elementWithTransformHandleType.element, elementWithTransformHandleType.transformHandleType === "rotation"
}); ) {
pointerDownState.resize.handleType = this.setState({
elementWithTransformHandleType.transformHandleType; resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
} else if (this.state.croppingElementId) {
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
} else {
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
}
} }
} else if (selectedElements.length > 1) { } else if (selectedElements.length > 1) {
pointerDownState.resize.handleType = getTransformHandleTypeFromCoords( pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
@@ -6835,6 +6941,13 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y, pointerDownState.origin.y,
); );
if (
this.state.croppingElementId &&
pointerDownState.hit.element?.id !== this.state.croppingElementId
) {
this.finishImageCropping();
}
if (pointerDownState.hit.element) { if (pointerDownState.hit.element) {
// Early return if pointer is hitting link icon // Early return if pointer is hitting link icon
const hitLinkElement = this.getElementLinkAtPosition( const hitLinkElement = this.getElementLinkAtPosition(
@@ -7282,7 +7395,6 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
roundness: null, roundness: null,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
...selectSubtype(this.state, "image"),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -7399,7 +7511,6 @@ class App extends React.Component<AppProps, AppState> {
null, null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
@@ -7419,7 +7530,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.currentItemRoundness === "round" this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS } ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null, : null,
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
@@ -7500,7 +7610,6 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: this.getCurrentItemRoundness(elementType), roundness: this.getCurrentItemRoundness(elementType),
...selectSubtype(this.state, elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
} as const; } as const;
@@ -7640,6 +7749,11 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
) { ) {
return withBatchedUpdatesThrottled((event: PointerEvent) => { return withBatchedUpdatesThrottled((event: PointerEvent) => {
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated // We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
// event handler should hopefully ensure we're already working with // event handler should hopefully ensure we're already working with
@@ -7662,8 +7776,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (isEraserActive(this.state)) { if (isEraserActive(this.state)) {
this.handleEraser(event, pointerDownState, pointerCoords); this.handleEraser(event, pointerDownState, pointerCoords);
return; return;
@@ -7700,6 +7812,9 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.resize.isResizing) { if (pointerDownState.resize.isResizing) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
if (this.maybeHandleCrop(pointerDownState, event)) {
return true;
}
if (this.maybeHandleResize(pointerDownState, event)) { if (this.maybeHandleResize(pointerDownState, event)) {
return true; return true;
} }
@@ -7827,10 +7942,14 @@ class App extends React.Component<AppProps, AppState> {
isFrameLikeElement(e), isFrameLikeElement(e),
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords); const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
this.setState({ const frameToHighlight =
frameToHighlight: topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null, // Only update the state if there is a difference
}); if (this.state.frameToHighlight !== frameToHighlight) {
flushSync(() => {
this.setState({ frameToHighlight });
});
}
// Marking that click was used for dragging to check // Marking that click was used for dragging to check
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
@@ -7873,6 +7992,96 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
// #region move crop region
if (this.state.croppingElementId) {
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (
croppingElement &&
isImageElement(croppingElement) &&
croppingElement.crop !== null &&
pointerDownState.hit.element === croppingElement
) {
const crop = croppingElement.crop;
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) {
const instantDragOffset = vectorScale(
vector(
pointerCoords.x - lastPointerCoords.x,
pointerCoords.y - lastPointerCoords.y,
),
Math.max(this.state.zoom.value, 2),
);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement,
elementsMap,
);
const topLeft = vectorFromPoint(
pointRotateRads(
pointFrom(x1, y1),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const topRight = vectorFromPoint(
pointRotateRads(
pointFrom(x2, y1),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const bottomLeft = vectorFromPoint(
pointRotateRads(
pointFrom(x1, y2),
pointFrom(cx, cy),
croppingElement.angle,
),
);
const topEdge = vectorNormalize(
vectorSubtract(topRight, topLeft),
);
const leftEdge = vectorNormalize(
vectorSubtract(bottomLeft, topLeft),
);
// project instantDrafOffset onto leftEdge and topEdge to decompose
const offsetVector = vector(
vectorDot(instantDragOffset, topEdge),
vectorDot(instantDragOffset, leftEdge),
);
const nextCrop = {
...crop,
x: clamp(
crop.x -
offsetVector[0] * Math.sign(croppingElement.scale[0]),
0,
image.naturalWidth - crop.width,
),
y: clamp(
crop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
),
};
mutateElement(croppingElement, {
crop: nextCrop,
});
return;
}
}
}
// Snap cache *must* be synchronously popuplated before initial drag, // Snap cache *must* be synchronously popuplated before initial drag,
// otherwise the first drag even will not snap, causing a jump before // otherwise the first drag even will not snap, causing a jump before
// it snaps to its position if previously snapped already. // it snaps to its position if previously snapped already.
@@ -7887,7 +8096,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
this.setState({ snapLines }); flushSync(() => {
this.setState({ snapLines });
});
// when we're editing the name of a frame, we want the user to be // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
@@ -8006,6 +8217,7 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
} }
return; return;
} }
} }
@@ -8254,15 +8466,18 @@ class App extends React.Component<AppProps, AppState> {
const { const {
newElement, newElement,
resizingElement, resizingElement,
croppingElementId,
multiElement, multiElement,
activeTool, activeTool,
isResizing, isResizing,
isRotating, isRotating,
isCropping,
} = this.state; } = this.state;
this.setState((prevState) => ({ this.setState((prevState) => ({
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
isCropping: false,
resizingElement: null, resizingElement: null,
selectionElement: null, selectionElement: null,
frameToHighlight: null, frameToHighlight: null,
@@ -8272,6 +8487,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null, originSnapOffset: null,
})); }));
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null); SnapCache.setVisibleGaps(null);
@@ -8754,6 +8971,20 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
// click outside the cropping region to exit
if (
// not in the cropping mode at all
!croppingElementId ||
// in the cropping mode
(croppingElementId &&
// not cropping and no hit element
((!hitElement && !isCropping) ||
// hitting something else
(hitElement && hitElement.id !== croppingElementId)))
) {
this.finishImageCropping();
}
const pointerStart = this.lastPointerDownEvent; const pointerStart = this.lastPointerDownEvent;
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
@@ -9009,7 +9240,12 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
} }
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { if (
pointerDownState.drag.hasOccurred ||
isResizing ||
isRotating ||
isCropping
) {
// We only allow binding via linear elements, specifically via dragging // We only allow binding via linear elements, specifically via dragging
// the endpoints ("start" or "end"). // the endpoints ("start" or "end").
const linearElements = this.scene const linearElements = this.scene
@@ -9115,7 +9351,7 @@ class App extends React.Component<AppProps, AppState> {
if (mimeType === MIME_TYPES.svg) { if (mimeType === MIME_TYPES.svg) {
try { try {
imageFile = SVGStringToFile( imageFile = SVGStringToFile(
await normalizeSVG(await imageFile.text()), normalizeSVG(await imageFile.text()),
imageFile.name, imageFile.name,
); );
} catch (error: any) { } catch (error: any) {
@@ -9183,16 +9419,15 @@ class App extends React.Component<AppProps, AppState> {
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>( return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
async (resolve, reject) => { async (resolve, reject) => {
try { try {
this.files = { this.addMissingFiles([
...this.files, {
[fileId]: {
mimeType, mimeType,
id: fileId, id: fileId,
dataURL, dataURL,
created: Date.now(), created: Date.now(),
lastRetrieved: Date.now(), lastRetrieved: Date.now(),
}, },
}; ]);
const cachedImageData = this.imageCache.get(fileId); const cachedImageData = this.imageCache.get(fileId);
if (!cachedImageData) { if (!cachedImageData) {
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
@@ -9223,7 +9458,7 @@ class App extends React.Component<AppProps, AppState> {
/** /**
* inserts image into elements array and rerenders * inserts image into elements array and rerenders
*/ */
private insertImageElement = async ( insertImageElement = async (
imageElement: ExcalidrawImageElement, imageElement: ExcalidrawImageElement,
imageFile: File, imageFile: File,
showCursorImagePreview?: boolean, showCursorImagePreview?: boolean,
@@ -9376,7 +9611,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private initializeImageDimensions = ( initializeImageDimensions = (
imageElement: ExcalidrawImageElement, imageElement: ExcalidrawImageElement,
forceNaturalSize = false, forceNaturalSize = false,
) => { ) => {
@@ -9424,7 +9659,13 @@ class App extends React.Component<AppProps, AppState> {
const x = imageElement.x + imageElement.width / 2 - width / 2; const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2; const y = imageElement.y + imageElement.height / 2 - height / 2;
mutateElement(imageElement, { x, y, width, height }); mutateElement(imageElement, {
x,
y,
width,
height,
crop: null,
});
} }
}; };
@@ -9591,6 +9832,7 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas.addEventListener( this.interactiveCanvas.addEventListener(
EVENT.TOUCH_START, EVENT.TOUCH_START,
this.onTouchStart, this.onTouchStart,
{ passive: false },
); );
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd); this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -9963,6 +10205,83 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private maybeHandleCrop = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
): boolean => {
// to crop, we must already be in the cropping mode, where croppingElement has been set
if (!this.state.croppingElementId) {
return false;
}
const transformHandleType = pointerDownState.resize.handleType;
const pointerCoords = pointerDownState.lastCoords;
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.getEffectiveGridSize(),
);
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (
transformHandleType &&
croppingElement &&
isImageElement(croppingElement)
) {
const croppingAtStateStart = pointerDownState.originalElements.get(
croppingElement.id,
);
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (
croppingAtStateStart &&
isImageElement(croppingAtStateStart) &&
image &&
!(image instanceof Promise)
) {
mutateElement(
croppingElement,
cropElement(
croppingElement,
transformHandleType,
image.naturalWidth,
image.naturalHeight,
x,
y,
event.shiftKey
? croppingAtStateStart.width / croppingAtStateStart.height
: undefined,
),
);
updateBoundElements(
croppingElement,
this.scene.getNonDeletedElementsMap(),
{
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
},
);
this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation",
});
}
return true;
}
return false;
};
private maybeHandleResize = ( private maybeHandleResize = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent, event: MouseEvent | KeyboardEvent,
@@ -9979,7 +10298,9 @@ class App extends React.Component<AppProps, AppState> {
// Frames cannot be rotated. // Frames cannot be rotated.
(selectedFrames.length > 0 && transformHandleType === "rotation") || (selectedFrames.length > 0 && transformHandleType === "rotation") ||
// Elbow arrows cannot be transformed (resized or rotated). // Elbow arrows cannot be transformed (resized or rotated).
(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
// Do not resize when in crop mode
this.state.croppingElementId
) { ) {
return false; return false;
} }
@@ -10080,39 +10401,6 @@ class App extends React.Component<AppProps, AppState> {
const elementsToHighlight = new Set<ExcalidrawElement>(); const elementsToHighlight = new Set<ExcalidrawElement>();
selectedFrames.forEach((frame) => { selectedFrames.forEach((frame) => {
const elementsInFrame = getFrameChildren(
this.scene.getNonDeletedElements(),
frame.id,
);
// keep elements' positions relative to their frames on frames resizing
if (transformHandleType) {
if (transformHandleType.includes("w")) {
elementsInFrame.forEach((element) => {
mutateElement(element, {
x:
frame.x +
(frameElementsOffsetsMap.get(frame.id + element.id)?.x || 0),
y:
frame.y +
(frameElementsOffsetsMap.get(frame.id + element.id)?.y || 0),
});
});
}
if (transformHandleType.includes("n")) {
elementsInFrame.forEach((element) => {
mutateElement(element, {
x:
frame.x +
(frameElementsOffsetsMap.get(frame.id + element.id)?.x || 0),
y:
frame.y +
(frameElementsOffsetsMap.get(frame.id + element.id)?.y || 0),
});
});
}
}
getElementsInResizingFrame( getElementsInResizingFrame(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
frame, frame,
@@ -10133,29 +10421,6 @@ class App extends React.Component<AppProps, AppState> {
private getContextMenuItems = ( private getContextMenuItems = (
type: "canvas" | "element", type: "canvas" | "element",
): ContextMenuItems => {
const subtype: ContextMenuItems = [];
this.actionManager
.filterActions(isSubtypeAction)
.forEach(
(action) =>
this.actionManager.isActionEnabled(action, { data: {} }) &&
subtype.push(action),
);
if (subtype.length > 0) {
subtype.push(CONTEXT_MENU_SEPARATOR);
}
const standard: ContextMenuItems = this._getContextMenuItems(type).filter(
(item) =>
!item ||
item === CONTEXT_MENU_SEPARATOR ||
this.actionManager.isActionEnabled(item, { noPredicates: true }),
);
return [...subtype, ...standard];
};
private _getContextMenuItems = (
type: "canvas" | "element",
): ContextMenuItems => { ): ContextMenuItems => {
const options: ContextMenuItems = []; const options: ContextMenuItems = [];
@@ -10210,6 +10475,8 @@ class App extends React.Component<AppProps, AppState> {
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleCropEditor,
CONTEXT_MENU_SEPARATOR,
...options, ...options,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionCopyStyles, actionCopyStyles,
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
}); });
if (!baseColorName) { if (!baseColorName) {
onChange(COLOR_PALETTE.black); onChange(COLOR_PALETTE.charcoal);
} }
} }
@@ -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],
+11 -1
View File
@@ -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 &&
@@ -23,7 +23,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import type { NonDeletedExcalidrawElement } from "../element/types"; import type { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../../utils/export";
import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -36,6 +35,7 @@ import { FilledButton } from "./FilledButton";
import { cloneJSON } from "../utils"; import { cloneJSON } from "../utils";
import { prepareElementsForExport } from "../data"; import { prepareElementsForExport } from "../data";
import { useCopyStatus } from "../hooks/useCopiedIndicator"; import { useCopyStatus } from "../hooks/useCopiedIndicator";
import { exportToCanvas } from "../scene/export";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -123,19 +123,25 @@ const ImageExportModal = ({
} }
exportToCanvas({ exportToCanvas({
elements: exportedElements, data: {
appState: { elements: exportedElements,
...appStateSnapshot, appState: {
name: projectName, ...appStateSnapshot,
exportBackground: exportWithBackground, name: projectName,
exportWithDarkMode: exportDarkMode, exportEmbedScene: embedScene,
exportScale, },
exportEmbedScene: embedScene, files,
},
config: {
canvasBackgroundColor: !exportWithBackground
? false
: appStateSnapshot.viewBackgroundColor,
padding: DEFAULT_EXPORT_PADDING,
theme: exportDarkMode ? "dark" : "light",
scale: exportScale,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}, },
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null); setRenderError(null);
@@ -56,7 +56,6 @@ import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { SubtypeToggles } from "./Subtypes";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
@@ -300,7 +299,6 @@ const LayerUI = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<SubtypeToggles />
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
@@ -24,7 +24,6 @@ import { PenModeButton } from "./PenModeButton";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { SubtypeToggles } from "./Subtypes";
type MobileMenuProps = { type MobileMenuProps = {
appState: UIAppState; appState: UIAppState;
@@ -90,7 +89,6 @@ export const MobileMenu = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
<SubtypeToggles />
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
@@ -1,9 +1,9 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react"; import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import type { ChartElements, Spreadsheet } from "../charts"; import type { ChartElements, Spreadsheet } from "../charts";
import { renderSpreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts";
import type { ChartType, ElementsMap } from "../element/types"; import type { ChartType } from "../element/types";
import { COLOR_WHITE } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import type { UIAppState } from "../types"; import type { UIAppState } from "../types";
@@ -11,12 +11,6 @@ import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
import { ensureSubtypesLoaded } from "../element/subtypes";
import { isTextElement } from "../element";
import {
getContainerElement,
redrawTextBoundingBox,
} from "../element/textElement";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -32,64 +26,43 @@ const ChartPreviewBtn = (props: {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
let svg: SVGSVGElement; let svg: SVGSVGElement;
const previewNode = previewRef.current!; const previewNode = previewRef.current!;
(async () => { (async () => {
(async () => { svg = await exportToSvg({
let elements: ChartElements; data: {
await ensureSubtypesLoaded( elements,
props.spreadsheet?.activeSubtypes ?? [], appState: {
() => { exportBackground: false,
if (!props.spreadsheet) { viewBackgroundColor: COLOR_WHITE,
return;
}
elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
elements.forEach(
(el) =>
isTextElement(el) &&
redrawTextBoundingBox(
el,
getContainerElement(el, elementsMap),
elementsMap,
),
);
setChartElements(elements);
}, },
).then(async () => { files: null,
svg = await exportToSvg( },
elements, });
{ svg.querySelector(".style-fonts")?.remove();
exportBackground: false, previewNode.replaceChildren();
viewBackgroundColor: oc.white, previewNode.appendChild(svg);
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) { if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus(); (previewNode.parentNode as HTMLDivElement).focus();
} }
});
})();
return () => {
previewNode.replaceChildren();
};
})(); })();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]); }, [props.spreadsheet, props.chartType, props.selected]);
return ( return (
@@ -7,8 +7,8 @@ import { t } from "../i18n";
import Trans from "./Trans"; import Trans from "./Trans";
import type { LibraryItems, LibraryItem, UIAppState } from "../types"; import type { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../../utils/export";
import { import {
COLOR_WHITE,
EDITOR_LS_KEYS, EDITOR_LS_KEYS,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@@ -24,6 +24,7 @@ import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss"; import "./PublishLibrary.scss";
import { exportToCanvas, exportToSvg } from "../scene/export";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@@ -55,16 +56,20 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white; ctx.fillStyle = COLOR_WHITE;
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items // draw items
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) { for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({ const itemCanvas = await exportToCanvas({
elements: item.elements, data: {
files: null, elements: item.elements,
maxWidthOrHeight: BOX_SIZE, files: null,
},
config: {
maxWidthOrHeight: BOX_SIZE,
},
}); });
const { width, height } = itemCanvas; const { width, height } = itemCanvas;
@@ -126,14 +131,15 @@ const SingleLibraryItem = ({
} }
(async () => { (async () => {
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: libItem.elements, data: {
appState: { elements: libItem.elements,
...appState, appState: {
viewBackgroundColor: OpenColor.white, ...appState,
exportBackground: true, viewBackgroundColor: COLOR_WHITE,
exportBackground: true,
},
files: null,
}, },
files: null,
skipInliningFonts: true,
}); });
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
@@ -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]);
@@ -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)) {
@@ -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) {
-188
View File
@@ -1,188 +0,0 @@
import { getShortcutKey, updateActiveTool } from "../utils";
import { t } from "../i18n";
import type { Action } from "../actions/types";
import { makeCustomActionName } from "../actions/types";
import clsx from "clsx";
import type { Subtype, SubtypeRecord } from "../element/subtypes";
import {
getSubtypeNames,
hasAlwaysEnabledActions,
isSubtypeAction,
isValidSubtype,
subtypeCollides,
} from "../element/subtypes";
import type { ExcalidrawElement, Theme } from "../element/types";
import {
useExcalidrawActionManager,
useExcalidrawContainer,
useExcalidrawSetAppState,
} from "./App";
import type { ContextMenuItems } from "./ContextMenu";
import { Island } from "./Island";
export const SubtypeButton = (
subtype: Subtype,
parentType: SubtypeRecord["parents"][number],
icon: ({ theme }: { theme: Theme }) => JSX.Element,
key?: string,
) => {
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
const keyTest: Action["keyTest"] =
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
const subtypeAction: Action = {
name: makeCustomActionName(subtype),
label: t(`toolBar.${subtype}`),
trackEvent: false,
predicate: (...rest) => rest[4]?.subtype === subtype,
perform: (elements, appState) => {
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
const activeSubtypes: Subtype[] = [];
if (appState.activeSubtypes) {
activeSubtypes.push(...appState.activeSubtypes);
}
let activated = false;
if (inactive) {
// Ensure `element.subtype` is well-defined
if (!subtypeCollides(subtype, activeSubtypes)) {
activeSubtypes.push(subtype);
activated = true;
}
} else {
// Can only be active if appState.activeSubtypes is defined
// and contains subtype.
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
}
const type =
appState.activeTool.type !== "custom" &&
isValidSubtype(subtype, appState.activeTool.type)
? appState.activeTool.type
: parentType;
const activeTool = !inactive
? appState.activeTool
: updateActiveTool(appState, { type });
const selectedElementIds = activated ? {} : appState.selectedElementIds;
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
return {
appState: {
...appState,
activeSubtypes,
selectedElementIds,
selectedGroupIds,
activeTool,
},
storeAction: "capture",
};
},
keyTest,
PanelComponent: ({ elements, appState, updateData, data }) => (
<button
className={clsx("ToolIcon_type_button", "ToolIcon_type_button--show", {
ToolIcon: true,
"ToolIcon--selected":
appState.activeSubtypes !== undefined &&
appState.activeSubtypes.includes(subtype),
"ToolIcon--plain": true,
})}
title={`${t(`toolBar.${subtype}`)}${title}`}
aria-label={t(`toolBar.${subtype}`)}
onClick={() => {
updateData(null);
}}
onContextMenu={
data && "onContextMenu" in data
? (event: React.MouseEvent) => {
if (
appState.activeSubtypes === undefined ||
(appState.activeSubtypes !== undefined &&
!appState.activeSubtypes.includes(subtype))
) {
updateData(null);
}
data.onContextMenu(event, subtype);
}
: undefined
}
>
{
<div className="ToolIcon__icon" aria-hidden="true">
{icon.call(this, { theme: appState.theme })}
</div>
}
</button>
),
};
if (key === "") {
delete subtypeAction.keyTest;
}
return subtypeAction;
};
export const SubtypeToggles = () => {
const am = useExcalidrawActionManager();
const { container } = useExcalidrawContainer();
const setAppState = useExcalidrawSetAppState();
const onContextMenu = (
event: React.MouseEvent<HTMLButtonElement>,
subtype: string,
) => {
event.preventDefault();
const { top: offsetTop, left: offsetLeft } =
container!.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
const items: ContextMenuItems = [];
am.filterActions(isSubtypeAction).forEach(
(action) =>
am.isActionEnabled(action, { data: { subtype } }) && items.push(action),
);
setAppState({}, () => {
setAppState({
contextMenu: { top, left, items },
});
});
};
// Only render if one or more subtypes are registered
if (getSubtypeNames().length === 0) {
return <></>;
}
return (
<>
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
{getSubtypeNames().map((subtype) =>
am.renderAction(
makeCustomActionName(subtype),
hasAlwaysEnabledActions(subtype) ? { onContextMenu } : {},
),
)}
</Island>
</>
);
};
SubtypeToggles.displayName = "SubtypeToggles";
export const SubtypeShapeActions = (props: {
elements: readonly ExcalidrawElement[];
}) => {
const am = useExcalidrawActionManager();
return (
<>
{am
.filterActions(isSubtypeAction, { elements: props.elements })
.map((action) => am.renderAction(action.name))}
</>
);
};
SubtypeShapeActions.displayName = "SubtypeShapeActions";
@@ -91,12 +91,16 @@ export const convertMermaidToExcalidraw = async ({
}; };
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
elements: data.current.elements, data: {
files: data.current.files, elements: data.current.elements,
exportPadding: DEFAULT_EXPORT_PADDING, files: data.current.files,
maxWidthOrHeight: },
Math.max(parent.offsetWidth, parent.offsetHeight) * config: {
window.devicePixelRatio, padding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
},
}); });
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big) // likely prevent preview and export (e.g. canvas too big)
@@ -203,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,
}); });
@@ -97,7 +97,6 @@ const getRelevantAppStateProps = (
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId, pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: appState.exportScale,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
gridSize: appState.gridSize, gridSize: appState.gridSize,
@@ -107,6 +106,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 = (
+9
View File
@@ -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,
);
+32 -8
View File
@@ -1,7 +1,8 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types"; import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors"; import type { NormalizedZoomValue } from "./types";
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);
@@ -107,7 +108,6 @@ export const YOUTUBE_STATES = {
export const ENV = { export const ENV = {
TEST: "test", TEST: "test",
DEVELOPMENT: "development",
}; };
export const CLASSES = { export const CLASSES = {
@@ -116,6 +116,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 +139,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 +176,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;
@@ -166,6 +183,14 @@ export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const DEFAULT_ZOOM_VALUE = 1 as NormalizedZoomValue;
// -----------------------------------------------
// !!! these colors are tied to color picker !!!
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
export const COLOR_TRANSPARENT = "transparent";
// -----------------------------------------------
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING; export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence // a small epsilon to make side resizing always take precedence
@@ -174,8 +199,6 @@ const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD = export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON; 2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS // keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6"; export const COLOR_VOICE_CALL = "#a2f1a6";
@@ -286,6 +309,7 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_SMALLEST_EXPORT_SIZE = 20; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
@@ -366,8 +390,8 @@ export const DEFAULT_ELEMENT_PROPS: {
opacity: ExcalidrawElement["opacity"]; opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"]; locked: ExcalidrawElement["locked"];
} = { } = {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_CHARCOAL_BLACK,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_TRANSPARENT,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: 2, strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
@@ -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,
} }
`; `;
+18 -4
View File
@@ -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: {
+40 -27
View File
@@ -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}"`);
+8 -8
View File
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
const metadataChunk = tEXt.encode( const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw, MIME_TYPES.excalidraw,
JSON.stringify( JSON.stringify(
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");
+63 -45
View File
@@ -81,46 +81,53 @@ export const prepareElementsForExport = (
}; };
}; };
export const exportCanvas = async ( export const exportAsImage = async ({
type: Omit<ExportType, "backend">, type,
elements: ExportedElements, data,
appState: AppState, config,
files: BinaryFiles, }: {
{ type: Omit<ExportType, "backend">;
exportBackground, data: {
exportPadding = DEFAULT_EXPORT_PADDING, elements: ExportedElements;
viewBackgroundColor, appState: AppState;
name = appState.name || DEFAULT_FILENAME, files: BinaryFiles;
fileHandle = null, };
exportingFrame = null, config: {
}: {
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; padding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
/** filename, if applicable */ /** filename, if applicable */
name?: string; name?: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}, };
) => { }) => {
if (elements.length === 0) { // clone
const cfg = Object.assign({}, config);
cfg.padding = cfg.padding ?? DEFAULT_EXPORT_PADDING;
cfg.fileHandle = cfg.fileHandle ?? null;
cfg.exportingFrame = cfg.exportingFrame ?? null;
cfg.name = cfg.name || DEFAULT_FILENAME;
if (data.elements.length === 0) {
throw new Error(t("alerts.cannotExportEmptyCanvas")); throw new Error(t("alerts.cannotExportEmptyCanvas"));
} }
if (type === "svg" || type === "clipboard-svg") { if (type === "svg" || type === "clipboard-svg") {
const svgPromise = exportToSvg( const svgPromise = exportToSvg({
elements, data: {
{ elements: data.elements,
exportBackground, appState: {
exportWithDarkMode: appState.exportWithDarkMode, exportBackground: cfg.exportBackground,
viewBackgroundColor, exportWithDarkMode: data.appState.exportWithDarkMode,
exportPadding, viewBackgroundColor: data.appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: data.appState.exportScale,
exportEmbedScene: appState.exportEmbedScene && type === "svg", exportEmbedScene: data.appState.exportEmbedScene && type === "svg",
},
files: data.files,
}, },
files, config: { exportingFrame: cfg.exportingFrame, padding: cfg.padding },
{ exportingFrame }, });
);
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
svgPromise.then((svg) => { svgPromise.then((svg) => {
@@ -128,9 +135,9 @@ export const exportCanvas = async (
}), }),
{ {
description: "Export to SVG", description: "Export to SVG",
name, name: cfg.name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: data.appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle, fileHandle: cfg.fileHandle,
}, },
); );
} else if (type === "clipboard-svg") { } else if (type === "clipboard-svg") {
@@ -144,22 +151,33 @@ export const exportCanvas = async (
} }
} }
const tempCanvas = exportToCanvas(elements, appState, files, { const tempCanvas = exportToCanvas({
exportBackground, data,
viewBackgroundColor, config: {
exportPadding, canvasBackgroundColor: !cfg.exportBackground
exportingFrame, ? false
: cfg.viewBackgroundColor,
padding: cfg.padding,
theme: data.appState.exportWithDarkMode ? "dark" : "light",
scale: data.appState.exportScale,
fit: "none",
exportingFrame: cfg.exportingFrame,
},
}); });
if (type === "png") { if (type === "png") {
let blob = canvasToBlob(tempCanvas); const blob = canvasToBlob(tempCanvas);
if (data.appState.exportEmbedScene) {
if (appState.exportEmbedScene) { blob.then((blob) =>
blob = blob.then((blob) =>
import("./image").then(({ encodePngMetadata }) => import("./image").then(({ encodePngMetadata }) =>
encodePngMetadata({ encodePngMetadata({
blob, blob,
metadata: serializeAsJSON(elements, appState, files, "local"), metadata: serializeAsJSON(
data.elements,
data.appState,
data.files,
"local",
),
}), }),
), ),
); );
@@ -167,11 +185,11 @@ export const exportCanvas = async (
return fileSave(blob, { return fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name: cfg.name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away // FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349 // from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png", extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle: cfg.fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
+12 -7
View File
@@ -1,6 +1,7 @@
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
import { exportCanvas, prepareElementsForExport } from "."; import { prepareElementsForExport } from ".";
import { exportAsImage } from ".";
import { getFileHandleType, isImageFileHandleType } from "./blob"; import { getFileHandleType, isImageFileHandleType } from "./blob";
export const resaveAsImageWithScene = async ( export const resaveAsImageWithScene = async (
@@ -29,12 +30,16 @@ export const resaveAsImageWithScene = async (
false, false,
); );
await exportCanvas(fileHandleType, exportedElements, appState, files, { await exportAsImage({
exportBackground, type: fileHandleType,
viewBackgroundColor, data: { elements: exportedElements, appState, files },
name, config: {
fileHandle, exportBackground,
exportingFrame, viewBackgroundColor,
name,
fileHandle,
exportingFrame,
},
}); });
return { fileHandle }; return { fileHandle };
+6 -11
View File
@@ -121,8 +121,7 @@ const repairBinding = (
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & { T extends Required<Omit<ExcalidrawElement, "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -185,15 +184,16 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element || "customData" in extra) { if ("customData" in element || "customData" in extra) {
base.customData = base.customData =
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
return { return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
@@ -262,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
@@ -601,12 +602,6 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
if ("activeSubtypes" in appState) {
nextAppState.activeSubtypes = appState.activeSubtypes;
}
if ("customData" in appState) {
nextAppState.customData = appState.customData;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
+4 -4
View File
@@ -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,
), ),
}; };
+587
View File
@@ -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,
); );
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils"; import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textElement"; import { wrapText } from "./textWrapping";
import { isIframeElement } from "./typeChecks"; import { isIframeElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
+1 -1
View File
@@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg"; return node?.nodeName.toLowerCase() === "svg";
}; };
export const normalizeSVG = 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");
+3 -26
View File
@@ -5,23 +5,12 @@ import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "./subtypes";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "version" | "versionNonce" | "updated"
>; >;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
const map = getSubtypeMethods(subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration. // This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in // The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you // the same drawing. Note: this will trigger the component to update. Make sure you
@@ -32,8 +21,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true, informMutation = true,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
@@ -82,7 +69,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
} }
if (!didChangePoints) { if (!didChangePoints) {
key in oldUpdates && (increment = true);
continue; continue;
} }
} }
@@ -90,7 +76,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value; (element as any)[key] = value;
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -107,11 +92,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
if (increment) { element.version++;
element.version++; element.versionNonce = randomInteger();
element.versionNonce = randomInteger(); element.updated = getUpdatedTimestamp();
element.updated = getUpdatedTimestamp();
}
if (informMutation) { if (informMutation) {
Scene.getScene(element)?.triggerUpdate(); Scene.getScene(element)?.triggerUpdate();
@@ -127,8 +110,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
force = false, force = false,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) { for (const key in updates) {
const value = (updates as any)[key]; const value = (updates as any)[key];
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
@@ -140,7 +121,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue; continue;
} }
didChange = true; didChange = true;
key in oldUpdates && (increment = true);
} }
} }
@@ -148,9 +128,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
if (!increment) {
return { ...element, ...updates };
}
return { return {
...element, ...element,
...updates, ...updates,
+29 -59
View File
@@ -19,7 +19,12 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
} from "./types"; } from "./types";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils"; import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { bumpVersion, newElementWith } from "./mutateElement"; import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
@@ -27,11 +32,11 @@ import type { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
measureTextElement, measureText,
normalizeText, normalizeText,
wrapTextElement,
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "./textElement"; } from "./textElement";
import { wrapText } from "./textWrapping";
import { import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -43,30 +48,6 @@ import {
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
export const maybeGetSubtypeProps = (
obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
},
type: ExcalidrawElement["type"],
) => {
const data: typeof obj = {};
if ("subtype" in obj) {
data.subtype = obj.subtype;
}
if ("customData" in obj) {
data.customData = obj.customData;
}
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
delete data.subtype;
}
if (!("subtype" in data) && "customData" in data) {
delete data.customData;
}
return data as typeof obj;
};
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -81,8 +62,6 @@ export type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "subtype"
| "customData"
| "strokeStyle" | "strokeStyle"
| "fillStyle" | "fillStyle"
| "strokeColor" | "strokeColor"
@@ -120,10 +99,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(), id: rest.id || randomId(),
type, type,
x, x,
@@ -159,11 +136,8 @@ export const newElement = (
opts: { opts: {
type: ExcalidrawGenericElement["type"]; type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => { ): NonDeleted<ExcalidrawGenericElement> =>
const map = getSubtypeMethods(opts?.subtype); _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -256,12 +230,10 @@ export const newTextElement = (
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureTextElement( const metrics = measureText(
{ ...opts, fontSize, fontFamily, lineHeight }, text,
{ getFontString({ fontFamily, fontSize }),
text, lineHeight,
customData: opts.customData,
},
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -305,9 +277,11 @@ const getAdjustedDimensions = (
width: number; width: number;
height: number; height: number;
} => { } => {
let { width: nextWidth, height: nextHeight } = measureTextElement(element, { let { width: nextWidth, height: nextHeight } = measureText(
text: nextText, nextText,
}); getFontString(element),
element.lineHeight,
);
// wrapped text // wrapped text
if (!element.autoResize) { if (!element.autoResize) {
@@ -323,7 +297,11 @@ const getAdjustedDimensions = (
!element.containerId && !element.containerId &&
element.autoResize element.autoResize
) { ) {
const prevMetrics = measureTextElement(element); const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,
@@ -426,14 +404,12 @@ export const refreshTextDimensions = (
return; return;
} }
if (container || !textElement.autoResize) { if (container || !textElement.autoResize) {
text = wrapTextElement( text = wrapText(
textElement, text,
getFontString(textElement),
container container
? getBoundTextMaxWidth(container, textElement) ? getBoundTextMaxWidth(container, textElement)
: textElement.width, : textElement.width,
{
text,
},
); );
} }
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
@@ -448,8 +424,6 @@ export const newFreeDrawElement = (
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -465,8 +439,6 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -487,8 +459,6 @@ export const newArrowElement = (
elbowed?: boolean; elbowed?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => { ): NonDeleted<ExcalidrawArrowElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -507,10 +477,9 @@ 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> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return { return {
..._newElementBase<ExcalidrawImageElement>("image", opts), ..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements, // in the future we'll support changing stroke color for some SVG elements,
@@ -519,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";
@@ -739,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,
}, },
}); });
@@ -999,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);
+7 -3
View File
@@ -20,7 +20,7 @@ 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 {
pointFrom, pointFrom,
@@ -90,7 +90,11 @@ 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(
pointFrom(x1 - SPACING, y1 - SPACING), pointFrom(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING), pointFrom(x2 + SPACING, y2 + SPACING),
@@ -104,7 +108,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
pointOnLineSegment( pointOnLineSegment(
pointFrom(x, y), pointFrom(x, y),
side as LineSegment<Point>, side as LineSegment<Point>,
SPACING, ZOOMED_SIDE_RESIZING_THRESHOLD,
) )
) { ) {
return dir as TransformHandleType; return dir as TransformHandleType;
@@ -1,541 +0,0 @@
import { useEffect } from "react";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
} from "../types";
import { getNonDeletedElements } from "../";
import { getSelectedElements } from "../../scene";
import type { AppState, ExcalidrawImperativeAPI, ToolType } from "../../types";
import type { LangLdr } from "../../i18n";
import { registerCustomLangData } from "../../i18n";
import type {
Action,
ActionName,
ActionPredicateFn,
CustomActionName,
} from "../../actions/types";
import { makeCustomActionName } from "../../actions/types";
import { registerCustomShortcuts } from "../../actions/shortcuts";
import { register } from "../../actions/register";
import { hasBoundTextElement, isTextElement } from "../typeChecks";
import {
getBoundTextElement,
getContainerElement,
redrawTextBoundingBox,
} from "../textElement";
import { ShapeCache } from "../../scene/ShapeCache";
import Scene from "../../scene/Scene";
// Use "let" instead of "const" so we can dynamically add subtypes
let subtypeNames: readonly Subtype[] = [];
let parentTypeMap: readonly {
subtype: Subtype;
parentType: ExcalidrawElement["type"];
}[] = [];
let subtypeActionMap: readonly {
subtype: Subtype;
actions: readonly ActionName[];
}[] = [];
let disabledActionMap: readonly {
subtype: Subtype;
actions: readonly DisabledActionName[];
}[] = [];
let alwaysEnabledMap: readonly {
subtype: Subtype;
actions: readonly SubtypeActionName[];
}[] = [];
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly (ExcalidrawElement["type"] & ToolType)[];
actionNames?: readonly SubtypeActionName[];
disabledNames?: readonly DisabledActionName[];
shortcutMap?: Record<string, string[]>;
alwaysEnabledNames?: readonly SubtypeActionName[];
}>;
// Subtype Names
export type Subtype = Required<ExcalidrawElement>["subtype"];
export const getSubtypeNames = (): readonly Subtype[] => {
return subtypeNames;
};
export const isValidSubtype = (s: any, t: any): s is Subtype =>
parentTypeMap.find(
(val) => (val.subtype as any) === s && (val.parentType as any) === t,
) !== undefined;
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
// Subtype Actions
// Used for context menus in the shape chooser
export const hasAlwaysEnabledActions = (s: any): boolean => {
if (!isSubtypeName(s)) {
return false;
}
return alwaysEnabledMap.some((value) => value.subtype === s);
};
type SubtypeActionName = string;
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
subtypeActionMap.some((val) => val.actions.includes(s));
const addSubtypeAction = (action: Action) => {
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
register(action);
}
};
// Standard actions disabled by subtypes
type DisabledActionName = ActionName;
const isDisabledActionName = (s: any): s is DisabledActionName =>
disabledActionMap.some((val) => val.actions.includes(s));
// Is the `actionName` one of the subtype actions for `subtype`
// (if `isAdded` is true) or one of the standard actions disabled
// by `subtype` (if `isAdded` is false)?
const isForSubtype = (
subtype: ExcalidrawElement["subtype"],
actionName: ActionName,
isAdded: boolean,
) => {
const actions = isAdded ? subtypeActionMap : disabledActionMap;
const map = actions.find((value) => value.subtype === subtype);
if (map) {
return map.actions.includes(actionName);
}
return false;
};
export const isSubtypeAction: ActionPredicateFn = function (action) {
return isSubtypeActionName(action.name) && !isSubtypeName(action.name);
};
export const subtypeActionPredicate: ActionPredicateFn = function (
action,
elements,
appState,
app,
) {
// We always enable subtype actions. Also let through standard actions
// which no subtypes might have disabled.
if (
isSubtypeName(action.name) ||
(!isSubtypeActionName(action.name) && !isDisabledActionName(action.name))
) {
return true;
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const chosen = appState.editingTextElement
? [appState.editingTextElement, ...selectedElements]
: selectedElements;
// Now handle actions added by subtypes
if (isSubtypeActionName(action.name)) {
// Has any ExcalidrawElement enabled this actionName through having
// its subtype?
return (
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return isForSubtype(e.subtype, action.name, true);
}) ||
// Or has any active subtype enabled this actionName?
(appState.activeSubtypes !== undefined &&
appState.activeSubtypes?.some((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return false;
}
return isForSubtype(subtype, action.name, true);
})) ||
alwaysEnabledMap.some((value) => {
return value.actions.includes(action.name);
})
);
}
// Now handle standard actions disabled by subtypes
if (isDisabledActionName(action.name)) {
return (
// Has every ExcalidrawElement not disabled this actionName?
(chosen.every((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return !isForSubtype(e.subtype, action.name, false);
}) &&
// And has every active subtype not disabled this actionName?
(appState.activeSubtypes === undefined ||
appState.activeSubtypes?.every((subtype) => {
if (!isValidSubtype(subtype, appState.activeTool.type)) {
return true;
}
return !isForSubtype(subtype, action.name, false);
}))) ||
// Or can we find an ExcalidrawElement without a valid subtype
// which would disable this action if it had a valid subtype?
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return parentTypeMap.some(
(value) =>
value.parentType === e.type &&
!isValidSubtype(e.subtype, e.type) &&
isForSubtype(value.subtype, action.name, false),
);
}) ||
chosen.some((el) => {
const e = hasBoundTextElement(el)
? getBoundTextElement(el, app.scene.getElementsMapIncludingDeleted())!
: el;
return (
// Would the subtype of e by inself disable this action?
isForSubtype(e.subtype, action.name, false) &&
// Can we find an ExcalidrawElement which could have the same subtype
// as e but whose subtype does not disable this action?
chosen.some((el) => {
const e2 = hasBoundTextElement(el)
? getBoundTextElement(
el,
app.scene.getElementsMapIncludingDeleted(),
)!
: el;
return (
// Does e have a valid subtype whose parent types include the
// type of e2, and does the subtype of e2 not disable this action?
parentTypeMap
.filter((val) => val.subtype === e.subtype)
.some((val) => val.parentType === e2.type) &&
!isForSubtype(e2.subtype, action.name, false)
);
})
);
})
);
}
// Shouldn't happen
return true;
};
// Are any of the parent types of `subtype` shared by any subtype
// in the array?
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
const subtypeParents = parentTypeMap
.filter((value) => value.subtype === subtype)
.map((value) => value.parentType);
const subtypeArrayParents = subtypeArray.flatMap((s) =>
parentTypeMap
.filter((value) => value.subtype === s)
.map((value) => value.parentType),
);
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
};
// Subtype Methods
export type SubtypeMethods = {
clean: (
updates: Omit<
Partial<ExcalidrawElement>,
"id" | "version" | "versionNonce"
>,
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
ensureLoaded: (callback?: () => void) => Promise<void>;
measureText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => { width: number; height: number };
render: (
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
context: CanvasRenderingContext2D,
) => void;
renderSvg: (
svgRoot: SVGElement,
addToRoot: (node: SVGElement, element: ExcalidrawElement) => void,
element: NonDeleted<ExcalidrawElement>,
elementsMap: ElementsMap,
opt?: { offsetX?: number; offsetY?: number },
) => void;
wrapText: (
element: Pick<
ExcalidrawTextElement,
| "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>,
containerWidth: number,
next?: {
fontSize?: number;
text?: string;
customData?: ExcalidrawElement["customData"];
},
) => string;
};
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
const methodMaps = [] as Array<MethodMap>;
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
export const getSubtypeMethods = (
subtype: Subtype | undefined,
): Partial<SubtypeMethods> | undefined => {
const map = methodMaps.find((method) => method.subtype === subtype);
return map?.methods;
};
export const addSubtypeMethods = (
subtype: Subtype,
methods: Partial<SubtypeMethods>,
) => {
if (!methodMaps.find((method) => method.subtype === subtype)) {
methodMaps.push({ subtype, methods });
}
};
// For a given `ExcalidrawElement` type, return the active subtype
// and associated customData (if any) from the AppState. Assume
// only one subtype is active for a given `ExcalidrawElement` type
// at any given time.
export const selectSubtype = (
appState: {
activeSubtypes?: AppState["activeSubtypes"];
customData?: AppState["customData"];
},
type: ExcalidrawElement["type"],
): {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
} => {
if (appState.activeSubtypes === undefined) {
return {};
}
const subtype = appState.activeSubtypes.find((subtype) =>
isValidSubtype(subtype, type),
);
if (subtype === undefined) {
return {};
}
if (appState.customData === undefined || !(subtype in appState.customData)) {
return { subtype };
}
const customData = appState.customData[subtype];
return { subtype, customData };
};
// Callback to re-render subtyped `ExcalidrawElement`s after completing
// async loading of the subtype.
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
// Functions to prepare subtypes for use
export type SubtypePrepFn = (
addSubtypeAction: (action: Action) => void,
addLangData: (fallbackLangData: {}, setLanguageAux: LangLdr) => void,
onSubtypeLoaded?: SubtypeLoadedCb,
) => {
actions: Action[];
methods: Partial<SubtypeMethods>;
};
// This is the main method to set up the subtype. The optional
// `onSubtypeLoaded` callback may be used to re-render subtyped
// `ExcalidrawElement`s after the subtype has finished async loading.
// See the MathJax extension in `@excalidraw/extensions` for example.
export const prepareSubtype = (
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
onSubtypeLoaded?: SubtypeLoadedCb,
): { actions: readonly Action[] | null; methods: Partial<SubtypeMethods> } => {
const map = getSubtypeMethods(record.subtype);
if (map) {
return { actions: null, methods: map };
}
// Check for undefined/null subtypes and parentTypes
if (
record.subtype === undefined ||
record.subtype === "" ||
record.parents === undefined ||
record.parents.length === 0
) {
return { actions: null, methods: {} };
}
// Register the types
const subtype = record.subtype;
subtypeNames = [...subtypeNames, subtype];
record.parents.forEach((parentType) => {
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
});
if (record.actionNames) {
subtypeActionMap = [
...subtypeActionMap,
{
subtype,
actions: record.actionNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
if (record.disabledNames) {
disabledActionMap = [
...disabledActionMap,
{ subtype, actions: record.disabledNames },
];
}
if (record.alwaysEnabledNames) {
alwaysEnabledMap = [
...alwaysEnabledMap,
{
subtype,
actions: record.alwaysEnabledNames.map((actionName) =>
makeCustomActionName(actionName),
),
},
];
}
const customShortcutMap = record.shortcutMap;
if (customShortcutMap) {
const shortcutMap: Record<CustomActionName, string[]> = {};
for (const key in customShortcutMap) {
shortcutMap[makeCustomActionName(key)] = customShortcutMap[key];
}
registerCustomShortcuts(shortcutMap);
}
// Prepare the subtype
const { actions, methods } = subtypePrepFn(
addSubtypeAction,
registerCustomLangData,
onSubtypeLoaded,
);
// Register the subtype's methods
addSubtypeMethods(record.subtype, methods);
return { actions, methods };
};
// Ensure all subtypes are loaded before continuing, eg to
// render SVG previews of new charts. Chart-relevant subtypes
// include math equations in titles or non hand-drawn line styles.
export const ensureSubtypesLoadedForElements = async (
elements: readonly ExcalidrawElement[],
callback?: () => void,
) => {
// Only ensure the loading of subtypes which are actually needed.
// We don't want to be held up by eg downloading the MathJax SVG fonts
// if we don't actually need them yet.
const subtypesUsed = [] as Subtype[];
elements.forEach((el) => {
if (
"subtype" in el &&
isValidSubtype(el.subtype, el.type) &&
!subtypesUsed.includes(el.subtype)
) {
subtypesUsed.push(el.subtype);
}
});
await ensureSubtypesLoaded(subtypesUsed, callback);
};
export const ensureSubtypesLoaded = async (
subtypes: Subtype[],
callback?: () => void,
) => {
// Use a for loop so we can do `await map.ensureLoaded()`
for (let i = 0; i < subtypes.length; i++) {
const subtype = subtypes[i];
// Should be defined if prepareSubtype() has run
const map = getSubtypeMethods(subtype);
if (map?.ensureLoaded) {
await map.ensureLoaded();
}
}
if (callback) {
callback();
}
};
// Call this method after finishing any async loading for
// subtypes of ExcalidrawElement if the newly loaded code
// would change the rendering.
export const checkRefreshOnSubtypeLoad = (
hasSubtype: SubtypeCheckFn,
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = new Map() as ElementsMap;
for (const element of elements) {
if (!element.isDeleted) {
elementsMap.set(element.id, element);
}
}
let refreshNeeded = false;
const scenes: Scene[] = [];
getNonDeletedElements(elements).forEach((element) => {
// If the element is of the subtype that was just
// registered, update the element's dimensions, mark the
// element for a re-render, and indicate the scene needs a refresh.
if (hasSubtype(element)) {
ShapeCache.delete(element);
if (isTextElement(element)) {
redrawTextBoundingBox(
element,
getContainerElement(element, elementsMap),
elementsMap,
false,
);
}
refreshNeeded = true;
const scene = Scene.getScene(element);
if (scene && !scenes.includes(scene)) {
// Store in case we have multiple scenes
scenes.push(scene);
}
}
});
// Only inform each scene once
scenes.forEach((scene) => scene.triggerUpdate());
return refreshNeeded;
};
export const useSubtype = (
api: ExcalidrawImperativeAPI | null,
record: SubtypeRecord,
subtypePrepFn: SubtypePrepFn,
) => {
useEffect(() => {
if (api) {
const prep = api.addSubtype(record, subtypePrepFn);
if (prep) {
addSubtypeMethods(record.subtype, prep.methods);
if (prep.actions) {
prep.actions.forEach((action) => api.registerAction(action));
}
}
}
}, [api, record, subtypePrepFn]);
};
@@ -1,13 +0,0 @@
import type { Theme } from "../../../element/types";
import { createIcon, iconFillColor } from "../../../components/icons";
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
createIcon(
<path
fill={iconFillColor(theme)}
// fa-square-root-variable-solid
d="M289 24.2C292.5 10 305.3 0 320 0H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H345L239 487.8c-3.2 13-14.2 22.6-27.6 24s-26.1-5.5-32.1-17.5L76.2 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H96c12.1 0 23.2 6.8 28.6 17.7l73.3 146.6L289 24.2zM393.4 233.4c12.5-12.5 32.8-12.5 45.3 0L480 274.7l41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L525.3 320l41.4 41.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L480 365.3l-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 320l-41.4-41.4c-12.5-12.5-12.5-32.8 0-45.3z"
/>,
{ width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
);
File diff suppressed because it is too large Load Diff
@@ -1,15 +0,0 @@
import type { ExcalidrawImperativeAPI } from "../../../types";
import { useSubtype } from "../";
import { getMathSubtypeRecord } from "./types";
import { prepareMathSubtype } from "./implementation";
declare global {
module SREfeature {
function custom(locale: string): Promise<string>;
}
}
// The main hook to use the MathJax subtype
export const useMathSubtype = (api: ExcalidrawImperativeAPI | null) => {
useSubtype(api, getMathSubtypeRecord(), prepareMathSubtype);
};
@@ -1,15 +0,0 @@
{
"labels": {
"changeMathOnly": "Math display",
"mathOnlyTrue": "Math only",
"mathOnlyFalse": "Mixed text",
"resetUseTex": "Reset math input type",
"useTexTrueActive": "✔ Standard input",
"useTexTrueInactive": "Standard input",
"useTexFalseActive": "✔ Simplified input",
"useTexFalseInactive": "Simplified input"
},
"toolBar": {
"math": "Math"
}
}
@@ -1,76 +0,0 @@
import { vi } from "vitest";
import { render } from "../../../../tests/test-utils";
import { API } from "../../../../tests/helpers/api";
import { Excalidraw } from "../../../../index";
import { measureTextElement } from "../../../textElement";
import { ensureSubtypesLoaded } from "../../";
import { getMathSubtypeRecord } from "../types";
import { prepareMathSubtype } from "../implementation";
describe("mathjax loaded", () => {
beforeEach(async () => {
await render(<Excalidraw />);
API.addSubtype(getMathSubtypeRecord(), prepareMathSubtype);
await ensureSubtypesLoaded(["math"]);
});
it("text-only measurements match", async () => {
const text = "A quick brown fox jumps over the lazy dog.";
const elements = [
API.createElement({ type: "text", id: "A", text, subtype: "math" }),
API.createElement({ type: "text", id: "B", text }),
];
const metrics1 = measureTextElement(elements[0]);
const metrics2 = measureTextElement(elements[1]);
expect(metrics1).toStrictEqual(metrics2);
});
it("minimum height remains", async () => {
const elements = [
API.createElement({ type: "text", id: "A", text: "a" }),
API.createElement({
type: "text",
id: "B",
text: "\\(\\alpha\\)",
subtype: "math",
customData: { useTex: true },
}),
API.createElement({
type: "text",
id: "C",
text: "`beta`",
subtype: "math",
customData: { useTex: false },
}),
];
const height = measureTextElement(elements[0]).height;
const height1 = measureTextElement(elements[1]).height;
const height2 = measureTextElement(elements[2]).height;
expect(height).toEqual(height1);
expect(height).toEqual(height2);
});
it("converts math to svgs", async () => {
const svgDim = 42;
vi.spyOn(SVGElement.prototype, "getBoundingClientRect").mockImplementation(
() => new DOMRect(0, 0, svgDim, svgDim),
);
const elements = [];
const type = "text";
const subtype = "math";
let text = "Math ";
elements.push(API.createElement({ type, text }));
text = "Math \\(\\alpha\\)";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: true } }),
);
text = "Math `beta`";
elements.push(
API.createElement({ type, subtype, text, customData: { useTex: false } }),
);
const metrics = {
width: measureTextElement(elements[0]).width + svgDim,
height: svgDim,
};
expect(measureTextElement(elements[1])).toStrictEqual(metrics);
expect(measureTextElement(elements[2])).toStrictEqual(metrics);
});
});
@@ -1,17 +0,0 @@
import { getShortcutKey } from "../../../utils";
import type { SubtypeRecord } from "../";
// Exports
export const getMathSubtypeRecord = () => mathSubtype;
// Use `getMathSubtype` so we don't have to export this
const mathSubtype: SubtypeRecord = {
subtype: "math",
parents: ["text"],
actionNames: ["useTexTrue", "useTexFalse", "resetUseTex", "changeMathOnly"],
disabledNames: ["changeFontFamily"],
shortcutMap: {
resetUseTex: [getShortcutKey("Shift+R")],
},
alwaysEnabledNames: ["useTexTrue", "useTexFalse"],
};
+2 -227
View File
@@ -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", () => {
+32 -210
View File
@@ -1,5 +1,3 @@
import type { SubtypeMethods } from "./subtypes";
import { getSubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import type { import type {
ElementsMap, ElementsMap,
@@ -23,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";
@@ -32,30 +31,6 @@ import {
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => { export const normalizeText = (text: string) => {
return ( return (
normalizeEOL(text) normalizeEOL(text)
@@ -90,12 +65,18 @@ export const redrawTextBoundingBox = (
maxWidth = container maxWidth = container
? getBoundTextMaxWidth(container, textElement) ? getBoundTextMaxWidth(container, textElement)
: textElement.width; : textElement.width;
boundTextUpdates.text = wrapTextElement(textElement, maxWidth); boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureTextElement(textElement, { const metrics = measureText(
text: boundTextUpdates.text, boundTextUpdates.text,
}); getFontString(textElement),
textElement.lineHeight,
);
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
if (textElement.autoResize) { if (textElement.autoResize) {
@@ -103,14 +84,6 @@ export const redrawTextBoundingBox = (
} }
boundTextUpdates.height = metrics.height; boundTextUpdates.height = metrics.height;
// Maintain coordX for non left-aligned text in case the width has changed
if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
}
}
if (container) { if (container) {
const maxContainerHeight = getBoundTextMaxHeight( const maxContainerHeight = getBoundTextMaxHeight(
container, container,
@@ -219,9 +192,17 @@ export const handleBindTextResize = (
(transformHandleType !== "n" && transformHandleType !== "s") (transformHandleType !== "n" && transformHandleType !== "s")
) { ) {
if (text) { if (text) {
text = wrapTextElement(textElement, maxWidth); text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
} }
const metrics = measureTextElement(textElement, { text }); const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height; nextHeight = metrics.height;
nextWidth = metrics.width; nextWidth = metrics.width;
} }
@@ -363,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,
@@ -428,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円\n1234\n
\n弧\n点
\n改行 \n記号`);
});
it("should break Korean sentences", () => {
const text = `한국 안녕하세요! 이것은 테스트입니다.
보자: 원화1234
(), , .
 `;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
보자: \n화1234\n싸다
(), \n표, .
 \n각기호`);
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
:\n원화\n1234\n
(),\n쉼표, \n표.
\n전각기호`);
});
});
describe("When text contains leading whitespaces", () => {
const text = " \t Hello world";
it("should preserve leading whitespaces", () => {
const maxWidth = 120;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" \t Hello\nworld");
});
it("should break and collapse leading whitespaces when line breaks", () => {
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHello\nworld");
});
it("should break and collapse leading whitespaces whe words break", () => {
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHel\nlo\nwor\nld");
});
});
describe("When text contains trailing whitespaces", () => {
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 190;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should ignore trailing whitespaces when line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 400;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
});
it("should not ignore trailing whitespaces when word breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
});
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 180;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
});
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\nwhats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 130,
res: `Hello whats\nup`,
},
{
desc: "fit the container",
width: 240,
res: "Hello whats up",
},
{
desc: "push the word if its equal to max width",
width: 50,
res: `Hello\nwhats\nup`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello\n whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\n whats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 140,
res: `Hello\n whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 160,
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 120,
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 590,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("Test parseTokens", () => {
it("should tokenize latin", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
expect(parseTokens(text)).toEqual([
"Excalidraw",
" ",
"is",
" ",
"a",
" ",
"virtual",
" ",
"collaborative",
" ",
"whiteboard",
]);
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
" ",
"is",
" ",
"hosted",
" ",
"by",
" ",
"Wikimedia-",
" ",
"Foundation,",
" ",
"a",
" ",
"non-",
"profit",
" ",
"organization",
" ",
"that",
" ",
"also",
" ",
"hosts",
" ",
"a",
" ",
"range-",
"of",
" ",
"other",
" ",
"projects",
]);
});
it("should not tokenize number", () => {
const text = "99,100.99";
const tokens = parseTokens(text);
expect(tokens).toEqual(["99,100.99"]);
});
it("should tokenize joined emojis", () => {
const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"🌍",
"🗺",
"🔥",
"☂️",
"👩🏽‍🦰",
"👨‍👩‍👧‍👦",
"👩🏾‍🔬",
"🏳️‍🌈",
"🧔‍♀️",
"🧑‍🤝‍🧑",
"🙅🏽‍♂️",
"✅",
"0️⃣",
"🇨🇿",
"🦅",
]);
});
it("should tokenize emojis mixed with mixed text", () => {
const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"a",
"🌍",
"b",
"🗺",
"c",
"🔥",
"d",
"☂️",
"《",
"👩🏽‍🦰",
"》",
"👨‍👩‍👧‍👦",
"德",
"👩🏾‍🔬",
"こ",
"🏳️‍🌈",
"안",
"🧔‍♀️",
"g",
"🧑‍🤝‍🧑",
"h",
"🙅🏽‍♂️",
"e",
"✅",
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
"🇨🇿",
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
"🦅",
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
]);
});
it("should tokenize decomposed chars into their composed variants", () => {
// each input character is in a decomposed form
const text = "čでäぴέ다й한";
expect(text.normalize("NFC").length).toEqual(8);
expect(text).toEqual(text.normalize("NFD"));
const tokens = parseTokens(text);
expect(tokens.length).toEqual(8);
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
});
it("should tokenize artificial CJK", () => {
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
// [
// '《道', '德', '經》', '醫-',
// '醫', 'こ', 'ん', 'に',
// 'ち', 'は', '世', '界!',
// '안', '녕', '하', '세',
// '요', '세', '계;', '요』,',
// '다.', '다...', '원/', '달',
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
// 'た…', '[Hello]', ' ', '\t',
// ' ', 'World', 'ニ', 'ュ',
// 'ー', 'ヨ', 'ー', 'ク・',
// '¥3700.55', 'す。', '090-', '1234-',
// '5678', '¥1,000〜', '5,000', '「素',
// '晴', 'ら', 'し', 'い!」',
// '〔重', '要〕', '', '1:',
// 'Taro', '君', '30', 'は、',
// '(た', 'な', 'ば', 'た)',
// '〰', '¥110±', '¥570', 'で',
// '20℃〜', '9:30〜', '10:00', '【一',
// '番】'
// ]
const tokens = parseTokens(text);
// Latin
expect(tokens).toContain("[[1]]");
expect(tokens).toContain("[Hello]");
expect(tokens).toContain("World");
expect(tokens).toContain("Taro");
// Chinese
expect(tokens).toContain("《道");
expect(tokens).toContain("德");
expect(tokens).toContain("經》");
expect(tokens).toContain("醫-");
expect(tokens).toContain("醫");
// Japanese
expect(tokens).toContain("こ");
expect(tokens).toContain("ん");
expect(tokens).toContain("に");
expect(tokens).toContain("ち");
expect(tokens).toContain("は");
expect(tokens).toContain("世");
expect(tokens).toContain("ク・");
expect(tokens).toContain("界!");
expect(tokens).toContain("た…");
expect(tokens).toContain("す。");
expect(tokens).toContain("ュ");
expect(tokens).toContain("「素");
expect(tokens).toContain("晴");
expect(tokens).toContain("ら");
expect(tokens).toContain("し");
expect(tokens).toContain("い!」");
expect(tokens).toContain("君");
expect(tokens).toContain("は、");
expect(tokens).toContain("(た");
expect(tokens).toContain("な");
expect(tokens).toContain("ば");
expect(tokens).toContain("た)");
expect(tokens).toContain("で");
expect(tokens).toContain("【一");
expect(tokens).toContain("番】");
// Check for Korean
expect(tokens).toContain("안");
expect(tokens).toContain("녕");
expect(tokens).toContain("하");
expect(tokens).toContain("세");
expect(tokens).toContain("요");
expect(tokens).toContain("세");
expect(tokens).toContain("계;");
expect(tokens).toContain("요』,");
expect(tokens).toContain("다.");
expect(tokens).toContain("다...");
expect(tokens).toContain("원/");
expect(tokens).toContain("달");
expect(tokens).toContain("(((다)))");
expect(tokens).toContain("〚({((한))>)〛");
expect(tokens).toContain("(「た」)");
// Numbers and units
expect(tokens).toContain("¥3700.55");
expect(tokens).toContain("090-");
expect(tokens).toContain("1234-");
expect(tokens).toContain("5678");
expect(tokens).toContain("¥1,000〜");
expect(tokens).toContain("5,000");
expect(tokens).toContain("1:");
expect(tokens).toContain("30");
expect(tokens).toContain("¥110±");
expect(tokens).toContain("20℃〜");
expect(tokens).toContain("9:30〜");
expect(tokens).toContain("10:00");
// Punctuation and symbols
expect(tokens).toContain(" ");
expect(tokens).toContain("\t");
expect(tokens).toContain(" ");
expect(tokens).toContain("ニ");
expect(tokens).toContain("ー");
expect(tokens).toContain("ヨ");
expect(tokens).toContain("〰");
expect(tokens).toContain("");
});
});
});
+568
View File
@@ -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
*
* Price100 ["Price", "¥100"]
* BREAK BEFORE "¥"
*/
CURRENCY: //u,
};
const EMOJI = {
FLAG: /\p{RI}\p{RI}/u,
JOINER:
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
ZWJ: /\u200D/u,
ANY: /[\p{Emoji}]/u,
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
};
/**
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
*
* Browser support as of 10/2024:
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
*
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
*/
const getLineBreakRegexSimple = () =>
Regex.or(
getEmojiRegex(),
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
);
/**
* Specifies the line breaking rules based for alphabetic-based languages,
* Chinese, Japanese, Korean and Emojis.
*
* "Hello-world" ["Hello-", "world"]
* "Hello 「世界。」🌎🗺" ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
*/
const getLineBreakRegexAdvanced = () =>
Regex.or(
// Unicode-defined regex for (multi-codepoint) Emojis
getEmojiRegex(),
// Rules for whitespace and hyphen
Break.Before(COMMON.WHITESPACE).Build(),
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
// Rules for CJK (chars, symbols, currency)
Break.Before(CJK.CHAR, CJK.CURRENCY)
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
.Build(),
Break.After(CJK.CHAR)
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
.Build(),
// Rules for opening and closing punctuation
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
);
/**
* Matches various emoji types.
*
* 1. basic emojis (😀, 🌍)
* 2. flags (🇨🇿)
* 3. multi-codepoint emojis:
* - skin tones (👍🏽)
* - variation selectors ()
* - keycaps (1)
* - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
* - emoji sequences (👨👩👧👦, 👩🚀, 🏳🌈)
*
* Unicode points:
* - \uFE0F: presentation selector
* - \u20E3: enclosing keycap
* - \u200D: zero width joiner
* - \u{E0020}-\u{E007E}: tags
* - \u{E007F}: cancel tag
*
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
*/
const getEmojiRegexUnicode = () =>
Regex.group(
Regex.or(
EMOJI.FLAG,
Regex.and(
EMOJI.MOST,
EMOJI.JOINER,
Regex.build(
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
),
),
),
);
/**
* Regex utilities for unicode character classes.
*/
const Regex = {
/**
* Builds a regex from a string.
*/
build: (regex: string): RegExp => new RegExp(regex, "u"),
/**
* Joins regexes into a single string.
*/
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
/**
* Joins regexes into a single regex as with "and" operator.
*/
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
/**
* Joins regexes into a single regex with "or" operator.
*/
or: (...regexes: RegExp[]): RegExp =>
Regex.build(regexes.map((x) => x.source).join("|")),
/**
* Puts regexes into a matching group.
*/
group: (...regexes: RegExp[]): RegExp =>
Regex.build(`(${Regex.join(...regexes)})`),
/**
* Puts regexes into a character class.
*/
class: (...regexes: RegExp[]): RegExp =>
Regex.build(`[${Regex.join(...regexes)}]`),
};
/**
* Human-readable lookahead and lookbehind utilities for defining line break
* opportunities between pairs of character classes.
*/
const Break = {
/**
* Break on the given class of characters.
*/
On: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
return Regex.build(`([${joined}])`);
},
/**
* Break before the given class of characters.
*/
Before: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after the given class of characters.
*/
After: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Break before one or multiple characters of the same class.
*/
BeforeMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after one or multiple character from the same class.
*/
AfterMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Do not break before the given class of characters.
*/
NotBefore: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
/**
* Do not break after the given class of characters.
*/
NotAfter: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
Chain: (rootBuilder: () => RegExp) => ({
/**
* Build the root regex.
*/
Build: rootBuilder,
/**
* Specify additional class of characters that should precede the root regex.
*/
PreceededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const preceeded = Break.After(...regexes).Build();
const builder = () => Regex.and(preceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Specify additional class of characters that should follow the root regex.
*/
FollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const followed = Break.Before(...regexes).Build();
const builder = () => Regex.and(root, followed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Specify additional class of characters that should not precede the root regex.
*/
NotPrecededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notPreceeded = Break.NotAfter(...regexes).Build();
const builder = () => Regex.and(notPreceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
/**
* Specify additional class of characters that should not follow the root regex.
*/
NotFollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notFollowed = Break.NotBefore(...regexes).Build();
const builder = () => Regex.and(root, notFollowed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
}),
};
/**
* Breaks the line into the tokens based on the found line break opporutnities.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
// normalizing to single-codepoint composed chars due to canonical equivalence
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
// filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
};
/**
* Wraps the original text into the lines based on the given width.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
}
return lines.join("\n");
};
/**
* Wraps the original line into the lines based on the given width.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineWidth = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
currentLine = testLine;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true);
iterator = tokenIterator.next();
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth);
lines.push(trailingLine);
}
return lines;
};
/**
* Wraps the word into the lines based on the given width.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [word];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineWidth = 0;
for (const char of chars) {
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
currentLine = currentLine + char;
currentLineWidth = testLineWidth;
continue;
}
if (currentLine) {
lines.push(currentLine);
}
currentLine = char;
currentLineWidth = _charWidth;
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
/**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*/
const trimLine = (line: string, font: FontString, maxWidth: number) => {
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
}
// defensively default to `trimeEnd` in case the regex does not match
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
line,
line.trimEnd(),
"",
];
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font);
const testLineWidth = trimmedLineWidth + _charWidth;
if (testLineWidth > maxWidth) {
break;
}
trimmedLine = trimmedLine + whitespace;
trimmedLineWidth = testLineWidth;
}
return trimmedLine;
};
/**
* Check if the given string is a single character.
*
* Handles multi-byte chars (é, ) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰).
*/
const isSingleCharacter = (maybeSingleCharacter: string) => {
return (
maybeSingleCharacter.codePointAt(0) !== undefined &&
maybeSingleCharacter.codePointAt(1) === undefined
);
};
/**
* Invariant for the word wrapping algorithm.
*/
const satisfiesWordInvariant = (word: string) => {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
if (/\s/.test(word)) {
throw new Error("Word should not contain any whitespaces!");
}
}
};
@@ -917,7 +917,7 @@ describe("textWysiwyg", () => {
Keyboard.exitTextEditor(editor); 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,
+10 -67
View File
@@ -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,
@@ -25,16 +25,15 @@ import {
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
measureText,
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,
@@ -47,15 +46,12 @@ import {
import type App from "../components/App"; import type App from "../components/App";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
import type { SubtypeMethods } from "./subtypes";
import { getSubtypeMethods } from "./subtypes";
import { import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
const getTransform = ( const getTransform = (
offsetX: number,
width: number, width: number,
height: number, height: number,
angle: number, angle: number,
@@ -73,18 +69,9 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) { if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2; translateY = (maxHeight * (zoom.value - 1)) / 2;
} }
const offset = offsetX !== 0 ? ` translate(${offsetX}px, 0px)` : ""; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)${offset}`;
}; };
const getEditorStyle = function (element) {
const map = getSubtypeMethods(element.subtype);
if (map?.getEditorStyle) {
return map.getEditorStyle(element);
}
return {};
} as SubtypeMethods["getEditorStyle"];
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@@ -150,27 +137,14 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
// Editing metrics let width = updatedTextElement.width;
const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
getBoundTextMaxWidth(container, updatedTextElement),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
updatedTextElement.lineHeight,
);
let width = Math.max(updatedTextElement.width, eMetrics.width); // set to element height by default since that's
// Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let height = Math.max(updatedTextElement.height, eMetrics.height); let height = updatedTextElement.height;
let maxWidth = width; let maxWidth = updatedTextElement.width;
let maxHeight = height; let maxHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) { if (isArrowElement(container)) {
@@ -266,39 +240,11 @@ export const textWysiwyg = ({
width += 0.5; width += 0.5;
} }
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
let { width: w, height: h } = updatedTextElement;
// add 5% buffer otherwise it causes wysiwyg to jump // add 5% buffer otherwise it causes wysiwyg to jump
height *= 1.05; height *= 1.05;
h *= 1.05;
const transformOrigin =
updatedTextElement.width !== eMetrics.width ||
updatedTextElement.height !== eMetrics.height
? { transformOrigin: `${w / 2}px ${h / 2}px` }
: {};
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;
@@ -308,11 +254,9 @@ 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`,
...transformOrigin,
transform: getTransform( transform: getTransform(
offsetX,
width, width,
height, height,
getTextElementAngle(updatedTextElement, container), getTextElementAngle(updatedTextElement, container),
@@ -320,7 +264,6 @@ export const textWysiwyg = ({
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
), ),
padding: `0 ${padding}px`,
textAlign, textAlign,
verticalAlign, verticalAlign,
color: updatedTextElement.strokeColor, color: updatedTextElement.strokeColor,
@@ -361,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",
@@ -373,7 +317,6 @@ export const textWysiwyg = ({
whiteSpace, whiteSpace,
overflowWrap: "break-word", overflowWrap: "break-word",
boxSizing: "content-box", boxSizing: "content-box",
...getEditorStyle(element),
}); });
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
@@ -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 {
@@ -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,
); );
}; };
+11 -1
View File
@@ -76,7 +76,6 @@ type _ExcalidrawElementBase = Readonly<{
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean; locked: boolean;
subtype?: string;
customData?: Record<string, any>; customData?: Record<string, any>;
}>; }>;
@@ -133,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";
@@ -141,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<
+37
View File
@@ -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,
},
];

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