Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 913921631b | |||
| ec39094933 | |||
| ef20c8b9fa | |||
| 1c55160e3f | |||
| e49db1dd3c | |||
| cd8bfb06b5 | |||
| 7762b925ae | |||
| 113dfc0023 | |||
| 96b5cfc35d | |||
| 9da26fb7e0 | |||
| 392b362118 | |||
| 0e02366dee | |||
| b2a6a87b10 | |||
| ab8b3537b3 | |||
| d21e0008dd | |||
| 840f1428c4 | |||
| 2db5bbcb29 | |||
| 0927431d0d | |||
| 98c0a67333 | |||
| 57cf577376 | |||
| 6e0ee89ee4 | |||
| 35f778a734 | |||
| ee091d0dbd | |||
| ef9ea14a75 | |||
| df168a6883 | |||
| 798f5f4dfb | |||
| d9ad7c039b | |||
| 7c0239e693 | |||
| da33481fa3 | |||
| 70e0e8dc29 | |||
| 2734e646ca | |||
| dfaaff4432 | |||
| 03028eaa8c | |||
| 79b181bcdc | |||
| f9815b8b4f | |||
| 96ed8a4331 | |||
| 33b01d4e80 |
+12
-1
@@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu
|
|||||||
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||||
|
|
||||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||||
VITE_APP_PLUS_APP=https://app.excalidraw.com
|
VITE_APP_PLUS_APP=http://localhost:3000
|
||||||
|
|
||||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||||
|
|
||||||
@@ -37,3 +37,14 @@ VITE_APP_COLLAPSE_OVERLAY=true
|
|||||||
|
|
||||||
# Set this flag to false to disable eslint
|
# Set this flag to false to disable eslint
|
||||||
VITE_APP_ENABLE_ESLINT=true
|
VITE_APP_ENABLE_ESLINT=true
|
||||||
|
|
||||||
|
# Enable PWA in dev server
|
||||||
|
VITE_APP_ENABLE_PWA=false
|
||||||
|
|
||||||
|
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0
|
||||||
|
7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij
|
||||||
|
ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y
|
||||||
|
UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
|
||||||
|
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
|
||||||
|
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
|
||||||
|
HQIDAQAB'
|
||||||
|
|||||||
@@ -15,3 +15,18 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
|||||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
VITE_APP_ENABLE_TRACKING=false
|
VITE_APP_ENABLE_TRACKING=false
|
||||||
|
|
||||||
|
VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX
|
||||||
|
/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t
|
||||||
|
kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF
|
||||||
|
gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53
|
||||||
|
PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx
|
||||||
|
iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE
|
||||||
|
PQIDAQAB'
|
||||||
|
|
||||||
|
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
|
||||||
|
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
|
||||||
|
VITE_APP_COLLAPSE_OVERLAY=false
|
||||||
|
# Enable eslint in dev server
|
||||||
|
VITE_APP_ENABLE_ESLINT=false
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ jobs:
|
|||||||
semantic:
|
semantic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -25,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,
|
||||||
@@ -126,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();
|
||||||
|
|
||||||
@@ -606,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,
|
||||||
@@ -615,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()) {
|
||||||
@@ -1120,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;
|
||||||
|
};
|
||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const _debugRenderer = (
|
|||||||
scale,
|
scale,
|
||||||
normalizedWidth,
|
normalizedWidth,
|
||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
viewBackgroundColor: "transparent",
|
canvasBackgroundColor: "transparent",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply zoom
|
// Apply zoom
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ import type {
|
|||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
|
||||||
|
type FileVersion = Required<BinaryFileData>["version"];
|
||||||
|
|
||||||
export class FileManager {
|
export class FileManager {
|
||||||
/** files being fetched */
|
/** files being fetched */
|
||||||
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
||||||
|
private erroredFiles_fetch = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
true
|
||||||
|
>();
|
||||||
/** files being saved */
|
/** files being saved */
|
||||||
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savingFiles = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
/* files already saved to persistent storage */
|
/* files already saved to persistent storage */
|
||||||
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
|
||||||
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
private erroredFiles_save = new Map<
|
||||||
|
ExcalidrawImageElement["fileId"],
|
||||||
|
FileVersion
|
||||||
|
>();
|
||||||
|
|
||||||
private _getFiles;
|
private _getFiles;
|
||||||
private _saveFiles;
|
private _saveFiles;
|
||||||
@@ -37,8 +49,8 @@ export class FileManager {
|
|||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, true>;
|
||||||
}>;
|
}>;
|
||||||
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
|
||||||
savedFiles: Map<FileId, true>;
|
savedFiles: Map<FileId, BinaryFileData>;
|
||||||
erroredFiles: Map<FileId, true>;
|
erroredFiles: Map<FileId, BinaryFileData>;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
this._getFiles = getFiles;
|
this._getFiles = getFiles;
|
||||||
@@ -46,19 +58,28 @@ export class FileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns whether file is already saved or being processed
|
* returns whether file is saved/errored, or being processed
|
||||||
*/
|
*/
|
||||||
isFileHandled = (id: FileId) => {
|
isFileTracked = (id: FileId) => {
|
||||||
return (
|
return (
|
||||||
this.savedFiles.has(id) ||
|
this.savedFiles.has(id) ||
|
||||||
this.fetchingFiles.has(id) ||
|
|
||||||
this.savingFiles.has(id) ||
|
this.savingFiles.has(id) ||
|
||||||
this.erroredFiles.has(id)
|
this.fetchingFiles.has(id) ||
|
||||||
|
this.erroredFiles_fetch.has(id) ||
|
||||||
|
this.erroredFiles_save.has(id)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
isFileSaved = (id: FileId) => {
|
isFileSavedOrBeingSaved = (file: BinaryFileData) => {
|
||||||
return this.savedFiles.has(id);
|
const fileVersion = this.getFileVersion(file);
|
||||||
|
return (
|
||||||
|
this.savedFiles.get(file.id) === fileVersion ||
|
||||||
|
this.savingFiles.get(file.id) === fileVersion
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getFileVersion = (file: BinaryFileData) => {
|
||||||
|
return file.version ?? 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
saveFiles = async ({
|
saveFiles = async ({
|
||||||
@@ -71,13 +92,16 @@ export class FileManager {
|
|||||||
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
const fileData =
|
||||||
|
isInitializedImageElement(element) && files[element.fileId];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isInitializedImageElement(element) &&
|
fileData &&
|
||||||
files[element.fileId] &&
|
// NOTE if errored during save, won't retry due to this check
|
||||||
!this.isFileHandled(element.fileId)
|
!this.isFileSavedOrBeingSaved(fileData)
|
||||||
) {
|
) {
|
||||||
addedFiles.set(element.fileId, files[element.fileId]);
|
addedFiles.set(element.fileId, files[element.fileId]);
|
||||||
this.savingFiles.set(element.fileId, true);
|
this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +110,12 @@ export class FileManager {
|
|||||||
addedFiles,
|
addedFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [fileId] of savedFiles) {
|
for (const [fileId, fileData] of savedFiles) {
|
||||||
this.savedFiles.set(fileId, true);
|
this.savedFiles.set(fileId, this.getFileVersion(fileData));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fileId, fileData] of erroredFiles) {
|
||||||
|
this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -121,10 +149,10 @@ export class FileManager {
|
|||||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||||
|
|
||||||
for (const file of loadedFiles) {
|
for (const file of loadedFiles) {
|
||||||
this.savedFiles.set(file.id, true);
|
this.savedFiles.set(file.id, this.getFileVersion(file));
|
||||||
}
|
}
|
||||||
for (const [fileId] of erroredFiles) {
|
for (const [fileId] of erroredFiles) {
|
||||||
this.erroredFiles.set(fileId, true);
|
this.erroredFiles_fetch.set(fileId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loadedFiles, erroredFiles };
|
return { loadedFiles, erroredFiles };
|
||||||
@@ -160,7 +188,7 @@ export class FileManager {
|
|||||||
): element is InitializedExcalidrawImageElement => {
|
): element is InitializedExcalidrawImageElement => {
|
||||||
return (
|
return (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
this.isFileSaved(element.fileId) &&
|
this.savedFiles.has(element.fileId) &&
|
||||||
element.status === "pending"
|
element.status === "pending"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -169,7 +197,8 @@ export class FileManager {
|
|||||||
this.fetchingFiles.clear();
|
this.fetchingFiles.clear();
|
||||||
this.savingFiles.clear();
|
this.savingFiles.clear();
|
||||||
this.savedFiles.clear();
|
this.savedFiles.clear();
|
||||||
this.erroredFiles.clear();
|
this.erroredFiles_fetch.clear();
|
||||||
|
this.erroredFiles_save.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ export class LocalData {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
async saveFiles({ addedFiles }) {
|
async saveFiles({ addedFiles }) {
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles = new Map<FileId, BinaryFileData>();
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles = new Map<FileId, BinaryFileData>();
|
||||||
|
|
||||||
// before we use `storage` event synchronization, let's update the flag
|
// before we use `storage` event synchronization, let's update the flag
|
||||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||||
@@ -195,10 +195,10 @@ export class LocalData {
|
|||||||
[...addedFiles].map(async ([id, fileData]) => {
|
[...addedFiles].map(async ([id, fileData]) => {
|
||||||
try {
|
try {
|
||||||
await set(id, fileData, filesStore);
|
await set(id, fileData, filesStore);
|
||||||
savedFiles.set(id, true);
|
savedFiles.set(id, fileData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.set(id, fileData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
const erroredFiles = new Map<FileId, true>();
|
const erroredFiles: FileId[] = [];
|
||||||
const savedFiles = new Map<FileId, true>();
|
const savedFiles: FileId[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async ({ id, buffer }) => {
|
files.map(async ({ id, buffer }) => {
|
||||||
@@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({
|
|||||||
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
savedFiles.set(id, true);
|
savedFiles.push(id);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
erroredFiles.set(id, true);
|
erroredFiles.push(id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,12 +54,6 @@
|
|||||||
content="https://excalidraw.com/og-image-3.png"
|
content="https://excalidraw.com/og-image-3.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- General tags -->
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!------------------------------------------------------------------------->
|
<!------------------------------------------------------------------------->
|
||||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
<script>
|
<script>
|
||||||
@@ -124,12 +118,6 @@
|
|||||||
<!-- Following placeholder is replaced during the build step -->
|
<!-- Following placeholder is replaced during the build step -->
|
||||||
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
|
||||||
|
|
||||||
<% } else { %>
|
|
||||||
<script>
|
|
||||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
|
||||||
</script>
|
|
||||||
<% } %>
|
|
||||||
|
|
||||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
@@ -137,6 +125,12 @@
|
|||||||
type="text/css"
|
type="text/css"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<% } else { %>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
|
|||||||
Vendored
+3
@@ -29,6 +29,9 @@ interface ImportMetaEnv {
|
|||||||
// Enable eslint in dev server
|
// Enable eslint in dev server
|
||||||
VITE_APP_ENABLE_ESLINT: string;
|
VITE_APP_ENABLE_ESLINT: string;
|
||||||
|
|
||||||
|
// Enable PWA in dev server
|
||||||
|
VITE_APP_ENABLE_PWA: string;
|
||||||
|
|
||||||
VITE_APP_PLUS_LP: string;
|
VITE_APP_PLUS_LP: string;
|
||||||
|
|
||||||
VITE_APP_PLUS_APP: string;
|
VITE_APP_PLUS_APP: string;
|
||||||
|
|||||||
+223
-220
@@ -8,231 +8,234 @@ import { createHtmlPlugin } from "vite-plugin-html";
|
|||||||
import Sitemap from "vite-plugin-sitemap";
|
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")) {
|
|
||||||
const family = chunkInfo.name.split("-")[0];
|
|
||||||
return `fonts/${family}/[name][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: [
|
|
||||||
Sitemap({
|
|
||||||
hostname: "https://excalidraw.com",
|
|
||||||
outDir: "build",
|
outDir: "build",
|
||||||
changefreq: "monthly",
|
rollupOptions: {
|
||||||
// its static in public folder
|
output: {
|
||||||
generateRobotsTxt: false,
|
assetFileNames(chunkInfo) {
|
||||||
}),
|
if (chunkInfo?.name?.endsWith(".woff2")) {
|
||||||
woff2BrowserPlugin(),
|
const family = chunkInfo.name.split("-")[0];
|
||||||
react(),
|
return `fonts/${family}/[name][extname]`;
|
||||||
checker({
|
}
|
||||||
typescript: true,
|
|
||||||
eslint:
|
|
||||||
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
|
||||||
? undefined
|
|
||||||
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
|
||||||
overlay: {
|
|
||||||
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
|
||||||
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
svgrPlugin(),
|
|
||||||
ViteEjsPlugin(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: "autoUpdate",
|
|
||||||
devOptions: {
|
|
||||||
/* set this flag to true to enable in Development mode */
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
workbox: {
|
return "assets/[name]-[hash][extname]";
|
||||||
// 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],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
// 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(".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: "/",
|
|
||||||
display: "standalone",
|
|
||||||
theme_color: "#121212",
|
|
||||||
background_color: "#ffffff",
|
|
||||||
file_handlers: [
|
|
||||||
{
|
|
||||||
action: "/",
|
|
||||||
accept: {
|
|
||||||
"application/vnd.excalidraw+json": [".excalidraw"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
share_target: {
|
|
||||||
action: "/web-share-target",
|
|
||||||
method: "POST",
|
|
||||||
enctype: "multipart/form-data",
|
|
||||||
params: {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
name: "file",
|
|
||||||
accept: [
|
|
||||||
"application/vnd.excalidraw+json",
|
|
||||||
"application/json",
|
|
||||||
".excalidraw",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
screenshots: [
|
|
||||||
{
|
|
||||||
src: "/screenshots/virtual-whiteboard.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/wireframe.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/illustration.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/shapes.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/collaboration.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/screenshots/export.png",
|
|
||||||
type: "image/png",
|
|
||||||
sizes: "462x945",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
sourcemap: true,
|
||||||
createHtmlPlugin({
|
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||||
minify: true,
|
assetsInlineLimit: 0,
|
||||||
}),
|
},
|
||||||
],
|
plugins: [
|
||||||
publicDir: "../public",
|
Sitemap({
|
||||||
|
hostname: "https://excalidraw.com",
|
||||||
|
outDir: "build",
|
||||||
|
changefreq: "monthly",
|
||||||
|
// its static in public folder
|
||||||
|
generateRobotsTxt: false,
|
||||||
|
}),
|
||||||
|
woff2BrowserPlugin(),
|
||||||
|
react(),
|
||||||
|
checker({
|
||||||
|
typescript: true,
|
||||||
|
eslint:
|
||||||
|
envVars.VITE_APP_ENABLE_ESLINT === "false"
|
||||||
|
? undefined
|
||||||
|
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
|
||||||
|
overlay: {
|
||||||
|
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
|
||||||
|
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
svgrPlugin(),
|
||||||
|
ViteEjsPlugin(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
devOptions: {
|
||||||
|
/* set this flag to true to enable in Development mode */
|
||||||
|
enabled: envVars.VITE_APP_ENABLE_PWA === "true",
|
||||||
|
},
|
||||||
|
|
||||||
|
workbox: {
|
||||||
|
// don't precache fonts, locales and separate chunks
|
||||||
|
globIgnores: [
|
||||||
|
"fonts.css",
|
||||||
|
"**/locales/**",
|
||||||
|
"service-worker.js",
|
||||||
|
"**/*.chunk-*.js",
|
||||||
|
],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".+.woff2"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1000,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("fonts.css"),
|
||||||
|
handler: "StaleWhileRevalidate",
|
||||||
|
options: {
|
||||||
|
cacheName: "fonts",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp("locales/[^/]+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "locales",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".chunk-.+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "chunk",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
short_name: "Excalidraw",
|
||||||
|
name: "Excalidraw",
|
||||||
|
description:
|
||||||
|
"Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "android-chrome-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "apple-touch-icon.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-32x32.png",
|
||||||
|
sizes: "32x32",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "favicon-16x16.png",
|
||||||
|
sizes: "16x16",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
start_url: "/",
|
||||||
|
id:"excalidraw",
|
||||||
|
display: "standalone",
|
||||||
|
theme_color: "#121212",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
file_handlers: [
|
||||||
|
{
|
||||||
|
action: "/",
|
||||||
|
accept: {
|
||||||
|
"application/vnd.excalidraw+json": [".excalidraw"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
share_target: {
|
||||||
|
action: "/web-share-target",
|
||||||
|
method: "POST",
|
||||||
|
enctype: "multipart/form-data",
|
||||||
|
params: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
accept: [
|
||||||
|
"application/vnd.excalidraw+json",
|
||||||
|
"application/json",
|
||||||
|
".excalidraw",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
screenshots: [
|
||||||
|
{
|
||||||
|
src: "/screenshots/virtual-whiteboard.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/wireframe.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/illustration.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/shapes.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/collaboration.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/screenshots/export.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "462x945",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
minify: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
publicDir: "../public",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-10
@@ -45,7 +45,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
@@ -55,15 +55,9 @@
|
|||||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn --cwd ./excalidraw-app build",
|
||||||
"fix:code": "yarn test:code --fix",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"fix:other": "yarn prettier --write",
|
|
||||||
"fix": "yarn fix:other && yarn fix:code",
|
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
|
||||||
"prepare": "husky install",
|
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn --cwd ./excalidraw-app start",
|
||||||
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||||
"test:app": "vitest",
|
"test:app": "vitest",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -74,9 +68,15 @@
|
|||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest --coverage",
|
||||||
"test:coverage:watch": "vitest --coverage --watch",
|
"test:coverage:watch": "vitest --coverage --watch",
|
||||||
"test:ui": "yarn test --ui --coverage.enabled=true",
|
"test:ui": "yarn test --ui --coverage.enabled=true",
|
||||||
|
"fix:code": "yarn test:code --fix",
|
||||||
|
"fix:other": "yarn prettier --write",
|
||||||
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
|
"prepare": "husky install",
|
||||||
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"autorelease": "node scripts/autorelease.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"build:preview": "yarn build && vite preview --port 5000",
|
|
||||||
"release:excalidraw": "node scripts/release.js",
|
"release:excalidraw": "node scripts/release.js",
|
||||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
|
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
|
||||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||||
|
|||||||
@@ -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,17 +137,15 @@ 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({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
@@ -208,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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -173,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,
|
||||||
@@ -324,7 +321,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -49,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,
|
||||||
@@ -90,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 {
|
||||||
@@ -304,8 +304,10 @@ import { Toast } from "./Toast";
|
|||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import {
|
import {
|
||||||
dataURLToFile,
|
dataURLToFile,
|
||||||
|
dataURLToString,
|
||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
|
getDataURL_sync,
|
||||||
getFileFromEvent,
|
getFileFromEvent,
|
||||||
ImageURLToFile,
|
ImageURLToFile,
|
||||||
isImageFileHandle,
|
isImageFileHandle,
|
||||||
@@ -338,7 +340,6 @@ import {
|
|||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
measureText,
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
wrapText,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
@@ -459,6 +460,7 @@ import {
|
|||||||
vectorNormalize,
|
vectorNormalize,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import { cropElement } from "../element/cropElement";
|
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!);
|
||||||
@@ -1813,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);
|
||||||
@@ -2122,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2320,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) => {
|
||||||
@@ -2557,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,
|
||||||
@@ -2589,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) {
|
||||||
@@ -2610,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),
|
||||||
@@ -2620,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,
|
||||||
@@ -2641,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 },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3236,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();
|
||||||
@@ -3746,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"];
|
||||||
@@ -7882,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
|
||||||
@@ -8032,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
|
||||||
@@ -9285,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) {
|
||||||
@@ -9353,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();
|
||||||
@@ -9767,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);
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -10198,7 +10264,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
croppingElement,
|
croppingElement,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
{
|
{
|
||||||
oldSize: {
|
newSize: {
|
||||||
width: croppingElement.width,
|
width: croppingElement.width,
|
||||||
height: croppingElement.height,
|
height: croppingElement.height,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!baseColorName) {
|
if (!baseColorName) {
|
||||||
onChange(COLOR_PALETTE.black);
|
onChange(COLOR_PALETTE.charcoal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import oc from "open-color";
|
|
||||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import type { ChartElements, Spreadsheet } from "../charts";
|
import type { ChartElements, Spreadsheet } from "../charts";
|
||||||
import { renderSpreadsheet } from "../charts";
|
import { renderSpreadsheet } from "../charts";
|
||||||
import type { ChartType } from "../element/types";
|
import type { ChartType } from "../element/types";
|
||||||
|
import { COLOR_WHITE } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import type { UIAppState } from "../types";
|
import type { UIAppState } from "../types";
|
||||||
@@ -41,17 +41,16 @@ const ChartPreviewBtn = (props: {
|
|||||||
const previewNode = previewRef.current!;
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
svg = await exportToSvg(
|
svg = await exportToSvg({
|
||||||
elements,
|
data: {
|
||||||
{
|
elements,
|
||||||
exportBackground: false,
|
appState: {
|
||||||
viewBackgroundColor: oc.white,
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: COLOR_WHITE,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
},
|
},
|
||||||
null, // files
|
});
|
||||||
{
|
|
||||||
skipInliningFonts: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
svg.querySelector(".style-fonts")?.remove();
|
svg.querySelector(".style-fonts")?.remove();
|
||||||
previewNode.replaceChildren();
|
previewNode.replaceChildren();
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { t } from "../i18n";
|
|||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
|
|
||||||
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||||
import { exportToCanvas, exportToSvg } from "../../utils/export";
|
|
||||||
import {
|
import {
|
||||||
|
COLOR_WHITE,
|
||||||
EDITOR_LS_KEYS,
|
EDITOR_LS_KEYS,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
EXPORT_SOURCE,
|
EXPORT_SOURCE,
|
||||||
@@ -24,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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -183,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
|
||||||
@@ -191,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";
|
||||||
|
|
||||||
@@ -303,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;
|
||||||
|
|
||||||
@@ -383,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",
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { calculateScrollCenter } from "../scene";
|
|||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||||
|
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
||||||
import type { FileSystemHandle } from "./filesystem";
|
import type { FileSystemHandle } from "./filesystem";
|
||||||
import { nativeFileSystemSupported } from "./filesystem";
|
import { nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
import { restore, restoreLibraryItems } from "./restore";
|
import { restore, restoreLibraryItems } from "./restore";
|
||||||
import type { ImportedLibraryData } from "./types";
|
import type { ImportedLibraryData } from "./types";
|
||||||
|
|
||||||
const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === MIME_TYPES.png) {
|
if (blob.type === MIME_TYPES.png) {
|
||||||
@@ -46,9 +47,7 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return await (
|
return (await import("./image")).decodeSvgMetadata({
|
||||||
await import("./image")
|
|
||||||
).decodeSvgMetadata({
|
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -249,6 +248,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** async. For sync variant, use getDataURL_sync */
|
||||||
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -261,6 +261,16 @@ export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDataURL_sync = (
|
||||||
|
data: string | Uint8Array | ArrayBuffer,
|
||||||
|
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||||
|
): DataURL => {
|
||||||
|
return `data:${mimeType};base64,${stringToBase64(
|
||||||
|
toByteString(data),
|
||||||
|
true,
|
||||||
|
)}` as DataURL;
|
||||||
|
};
|
||||||
|
|
||||||
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||||
const dataIndexStart = dataURL.indexOf(",");
|
const dataIndexStart = dataURL.indexOf(",");
|
||||||
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
const byteString = atob(dataURL.slice(dataIndexStart + 1));
|
||||||
@@ -274,6 +284,10 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
|||||||
return new File([ab], filename, { type: mimeType });
|
return new File([ab], filename, { type: mimeType });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dataURLToString = (dataURL: DataURL) => {
|
||||||
|
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
|
||||||
|
};
|
||||||
|
|
||||||
export const resizeImageFile = async (
|
export const resizeImageFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
opts: {
|
opts: {
|
||||||
|
|||||||
@@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption";
|
|||||||
// byte (binary) strings
|
// byte (binary) strings
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// fast, Buffer-compatible implem
|
// Buffer-compatible implem.
|
||||||
export const toByteString = (
|
//
|
||||||
data: string | Uint8Array | ArrayBuffer,
|
// Note that in V8, spreading the uint8array (by chunks) into
|
||||||
): Promise<string> => {
|
// `String.fromCharCode(...uint8array)` tends to be faster for large
|
||||||
return new Promise((resolve, reject) => {
|
// strings/buffers, in case perf is needed in the future.
|
||||||
const blob =
|
export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
|
||||||
typeof data === "string"
|
const bytes =
|
||||||
? new Blob([new TextEncoder().encode(data)])
|
typeof data === "string"
|
||||||
: new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
|
? new TextEncoder().encode(data)
|
||||||
const reader = new FileReader();
|
: data instanceof Uint8Array
|
||||||
reader.onload = (event) => {
|
? data
|
||||||
if (!event.target || typeof event.target.result !== "string") {
|
: new Uint8Array(data);
|
||||||
return reject(new Error("couldn't convert to byte string"));
|
let bstring = "";
|
||||||
}
|
for (const byte of bytes) {
|
||||||
resolve(event.target.result);
|
bstring += String.fromCharCode(byte);
|
||||||
};
|
}
|
||||||
reader.readAsBinaryString(blob);
|
return bstring;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const byteStringToArrayBuffer = (byteString: string) => {
|
const byteStringToArrayBuffer = (byteString: string) => {
|
||||||
@@ -46,12 +45,12 @@ const byteStringToString = (byteString: string) => {
|
|||||||
* @param isByteString set to true if already byte string to prevent bloat
|
* @param isByteString set to true if already byte string to prevent bloat
|
||||||
* due to reencoding
|
* due to reencoding
|
||||||
*/
|
*/
|
||||||
export const stringToBase64 = async (str: string, isByteString = false) => {
|
export const stringToBase64 = (str: string, isByteString = false) => {
|
||||||
return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
|
return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
|
||||||
};
|
};
|
||||||
|
|
||||||
// async to align with stringToBase64
|
// async to align with stringToBase64
|
||||||
export const base64ToString = async (base64: string, isByteString = false) => {
|
export const base64ToString = (base64: string, isByteString = false) => {
|
||||||
return isByteString
|
return isByteString
|
||||||
? window.atob(base64)
|
? window.atob(base64)
|
||||||
: byteStringToString(window.atob(base64));
|
: byteStringToString(window.atob(base64));
|
||||||
@@ -66,6 +65,20 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
|||||||
return byteStringToArrayBuffer(atob(base64));
|
return byteStringToArrayBuffer(atob(base64));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// base64url
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const base64urlToString = (str: string) => {
|
||||||
|
return window.atob(
|
||||||
|
// normalize base64URL to base64
|
||||||
|
str
|
||||||
|
.replace(/-/g, "+")
|
||||||
|
.replace(/_/g, "/")
|
||||||
|
.padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// text encoding
|
// text encoding
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -82,18 +95,18 @@ type EncodedData = {
|
|||||||
/**
|
/**
|
||||||
* Encodes (and potentially compresses via zlib) text to byte string
|
* Encodes (and potentially compresses via zlib) text to byte string
|
||||||
*/
|
*/
|
||||||
export const encode = async ({
|
export const encode = ({
|
||||||
text,
|
text,
|
||||||
compress,
|
compress,
|
||||||
}: {
|
}: {
|
||||||
text: string;
|
text: string;
|
||||||
/** defaults to `true`. If compression fails, falls back to bstring alone. */
|
/** defaults to `true`. If compression fails, falls back to bstring alone. */
|
||||||
compress?: boolean;
|
compress?: boolean;
|
||||||
}): Promise<EncodedData> => {
|
}): EncodedData => {
|
||||||
let deflated!: string;
|
let deflated!: string;
|
||||||
if (compress !== false) {
|
if (compress !== false) {
|
||||||
try {
|
try {
|
||||||
deflated = await toByteString(deflate(text));
|
deflated = toByteString(deflate(text));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("encode: cannot deflate", error);
|
console.error("encode: cannot deflate", error);
|
||||||
}
|
}
|
||||||
@@ -102,11 +115,11 @@ export const encode = async ({
|
|||||||
version: "1",
|
version: "1",
|
||||||
encoding: "bstring",
|
encoding: "bstring",
|
||||||
compressed: !!deflated,
|
compressed: !!deflated,
|
||||||
encoded: deflated || (await toByteString(text)),
|
encoded: deflated || toByteString(text),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decode = async (data: EncodedData): Promise<string> => {
|
export const decode = (data: EncodedData): string => {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
|
|
||||||
switch (data.encoding) {
|
switch (data.encoding) {
|
||||||
@@ -114,7 +127,7 @@ export const decode = async (data: EncodedData): Promise<string> => {
|
|||||||
// if compressed, do not double decode the bstring
|
// if compressed, do not double decode the bstring
|
||||||
decoded = data.compressed
|
decoded = data.compressed
|
||||||
? data.encoded
|
? data.encoded
|
||||||
: await byteStringToString(data.encoded);
|
: byteStringToString(data.encoded);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
throw new Error(`decode: unknown encoding "${data.encoding}"`);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const encodePngMetadata = async ({
|
|||||||
const metadataChunk = tEXt.encode(
|
const metadataChunk = tEXt.encode(
|
||||||
MIME_TYPES.excalidraw,
|
MIME_TYPES.excalidraw,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
await encode({
|
encode({
|
||||||
text: metadata,
|
text: metadata,
|
||||||
compress: true,
|
compress: true,
|
||||||
}),
|
}),
|
||||||
@@ -59,7 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => {
|
|||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return decode(encodedData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
@@ -72,9 +72,9 @@ export const decodePngMetadata = async (blob: Blob) => {
|
|||||||
// SVG
|
// SVG
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
export const encodeSvgMetadata = ({ text }: { text: string }) => {
|
||||||
const base64 = await stringToBase64(
|
const base64 = stringToBase64(
|
||||||
JSON.stringify(await encode({ text })),
|
JSON.stringify(encode({ text })),
|
||||||
true /* is already byte string */,
|
true /* is already byte string */,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
|||||||
return metadata;
|
return metadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
|
||||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||||
const match = svg.match(
|
const match = svg.match(
|
||||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||||
@@ -100,7 +100,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
|||||||
const isByteString = version !== "1";
|
const isByteString = version !== "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = await base64ToString(match[1], isByteString);
|
const json = base64ToString(match[1], isByteString);
|
||||||
const encodedData = JSON.parse(json);
|
const encodedData = JSON.parse(json);
|
||||||
if (!("encoded" in encodedData)) {
|
if (!("encoded" in encodedData)) {
|
||||||
// legacy, un-encoded scene JSON
|
// legacy, un-encoded scene JSON
|
||||||
@@ -112,7 +112,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
|||||||
}
|
}
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
}
|
}
|
||||||
return await decode(encodedData);
|
return decode(encodedData);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Error("FAILED");
|
throw new Error("FAILED");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ import { getResizedElementAbsoluteCoords } from "./bounds";
|
|||||||
import {
|
import {
|
||||||
measureText,
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
wrapText,
|
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,642 +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", () => {
|
|
||||||
// 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 support multiple (multi-codepoint) emojis", () => {
|
|
||||||
const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
|
|
||||||
const maxWidth = 1;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should wrap the text correctly when text contains hyphen", () => {
|
|
||||||
let text =
|
|
||||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
|
||||||
const res = wrapText(text, font, 110);
|
|
||||||
expect(res).toBe(
|
|
||||||
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
|
|
||||||
);
|
|
||||||
|
|
||||||
text = "Hello thereusing-now";
|
|
||||||
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support wrapping nested lists", () => {
|
|
||||||
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
|
|
||||||
|
|
||||||
const maxWidth = 100;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
|
|
||||||
|
|
||||||
const maxWidth2 = 50;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("When text is CJK", () => {
|
|
||||||
it("should break each CJK character when width is very small", () => {
|
|
||||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
|
||||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
|
||||||
const maxWidth = 10;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe(
|
|
||||||
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break CJK text into longer segments when width is larger", () => {
|
|
||||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
|
||||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
|
||||||
const maxWidth = 30;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
|
|
||||||
// measureText is mocked, so it's not precisely what would happen in prod
|
|
||||||
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
|
|
||||||
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
|
|
||||||
|
|
||||||
const maxWidth = 150;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
|
|
||||||
|
|
||||||
const maxWidth2 = 50;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
|
|
||||||
|
|
||||||
const maxWidth3 = 30;
|
|
||||||
const res3 = wrapText(text, font, maxWidth3);
|
|
||||||
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break before and after a regular CJK character", () => {
|
|
||||||
const text = "HelloたWorld";
|
|
||||||
const maxWidth1 = 50;
|
|
||||||
const res1 = wrapText(text, font, maxWidth1);
|
|
||||||
expect(res1).toBe("Hello\nた\nWorld");
|
|
||||||
|
|
||||||
const maxWidth2 = 60;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe("Helloた\nWorld");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break before and after certain CJK symbols", () => {
|
|
||||||
const text = "こんにちは〃世界";
|
|
||||||
const maxWidth1 = 50;
|
|
||||||
const res1 = wrapText(text, font, maxWidth1);
|
|
||||||
expect(res1).toBe("こんにちは\n〃世界");
|
|
||||||
|
|
||||||
const maxWidth2 = 60;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe("こんにちは〃\n世界");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break after, not before for certain CJK pairs", () => {
|
|
||||||
const text = "Hello た。";
|
|
||||||
const maxWidth = 70;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("Hello\nた。");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break before, not after for certain CJK pairs", () => {
|
|
||||||
const text = "Hello「たWorld」";
|
|
||||||
const maxWidth = 60;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("Hello\n「た\nWorld」");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break after, not before for certain CJK character pairs", () => {
|
|
||||||
const text = "「Helloた」World";
|
|
||||||
const maxWidth = 70;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("「Hello\nた」World");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break Chinese sentences", () => {
|
|
||||||
const text = `中国你好!这是一个测试。
|
|
||||||
我们来看看:人民币¥1234「很贵」
|
|
||||||
(括号)、逗号,句号。空格 换行 全角符号…—`;
|
|
||||||
|
|
||||||
const maxWidth1 = 80;
|
|
||||||
const res1 = wrapText(text, font, maxWidth1);
|
|
||||||
expect(res1).toBe(`中国你好!这是一\n个测试。
|
|
||||||
我们来看看:人民\n币¥1234「很\n贵」
|
|
||||||
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
|
|
||||||
|
|
||||||
const maxWidth2 = 50;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe(`中国你好!\n这是一个测\n试。
|
|
||||||
我们来看\n看:人民币\n¥1234\n「很贵」
|
|
||||||
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break Japanese sentences", () => {
|
|
||||||
const text = `日本こんにちは!これはテストです。
|
|
||||||
見てみましょう:円¥1234「高い」
|
|
||||||
(括弧)、読点、句点。
|
|
||||||
空白 改行 全角記号…ー`;
|
|
||||||
|
|
||||||
const maxWidth1 = 80;
|
|
||||||
const res1 = wrapText(text, font, maxWidth1);
|
|
||||||
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
|
|
||||||
見てみましょ\nう:円¥1234\n「高い」
|
|
||||||
(括弧)、読\n点、句点。
|
|
||||||
空白 改行\n全角記号…ー`);
|
|
||||||
|
|
||||||
const maxWidth2 = 50;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
|
|
||||||
見てみ\nましょう:\n円\n¥1234\n「高い」
|
|
||||||
(括\n弧)、読\n点、句点。
|
|
||||||
空白\n改行 全角\n記号…ー`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break Korean sentences", () => {
|
|
||||||
const text = `한국 안녕하세요! 이것은 테스트입니다.
|
|
||||||
우리 보자: 원화₩1234「비싸다」
|
|
||||||
(괄호), 쉼표, 마침표.
|
|
||||||
공백 줄바꿈 전각기호…—`;
|
|
||||||
|
|
||||||
const maxWidth1 = 80;
|
|
||||||
const res1 = wrapText(text, font, maxWidth1);
|
|
||||||
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
|
|
||||||
우리 보자: 원\n화₩1234「비\n싸다」
|
|
||||||
(괄호), 쉼\n표, 마침표.
|
|
||||||
공백 줄바꿈 전\n각기호…—`);
|
|
||||||
|
|
||||||
const maxWidth2 = 60;
|
|
||||||
const res2 = wrapText(text, font, maxWidth2);
|
|
||||||
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
|
|
||||||
우리 보자:\n원화\n₩1234\n「비싸다」
|
|
||||||
(괄호),\n쉼표, 마침\n표.
|
|
||||||
공백 줄바꿈\n전각기호…—`);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("When text contains leading whitespaces", () => {
|
|
||||||
const text = " \t Hello world";
|
|
||||||
|
|
||||||
it("should preserve leading whitespaces", () => {
|
|
||||||
const maxWidth = 120;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe(" \t Hello\nworld");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break and collapse leading whitespaces when line breaks", () => {
|
|
||||||
const maxWidth = 60;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("\nHello\nworld");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should break and collapse leading whitespaces whe words break", () => {
|
|
||||||
const maxWidth = 30;
|
|
||||||
const res = wrapText(text, font, maxWidth);
|
|
||||||
expect(res).toBe("\nHel\nlo\nwor\nld");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("When text contains trailing whitespaces", () => {
|
|
||||||
it("shouldn't add new lines for trailing spaces", () => {
|
|
||||||
const text = "Hello whats up ";
|
|
||||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
|
||||||
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: 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: `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: 130,
|
|
||||||
res: `hellolongtex
|
|
||||||
tthisiswhats
|
|
||||||
upwithyouIam
|
|
||||||
typingggggan
|
|
||||||
dtypinggg
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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] World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
|
|
||||||
|
|
||||||
// [
|
|
||||||
// '《道', '德', '經》', '醫-',
|
|
||||||
// '醫', 'こ', 'ん', 'に',
|
|
||||||
// 'ち', 'は', '世', '界!',
|
|
||||||
// '안', '녕', '하', '세',
|
|
||||||
// '요', '세', '계;', '다.',
|
|
||||||
// '다...', '원/', '달', '(((다)))',
|
|
||||||
// '[[1]]', '〚({((한))>)〛', 'た…', '[Hello]',
|
|
||||||
// ' ', '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("で");
|
|
||||||
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("〚({((한))>)〛");
|
|
||||||
|
|
||||||
// Numbers and units
|
|
||||||
expect(tokens).toContain("¥3700.55");
|
|
||||||
expect(tokens).toContain("090-");
|
|
||||||
expect(tokens).toContain("1234-");
|
|
||||||
expect(tokens).toContain("5678¥1,000");
|
|
||||||
expect(tokens).toContain("$5,000");
|
|
||||||
expect(tokens).toContain("1:");
|
|
||||||
expect(tokens).toContain("30%");
|
|
||||||
expect(tokens).toContain("¥110±");
|
|
||||||
expect(tokens).toContain("¥570");
|
|
||||||
expect(tokens).toContain("20℃");
|
|
||||||
expect(tokens).toContain("9:30");
|
|
||||||
expect(tokens).toContain("10:00");
|
|
||||||
|
|
||||||
// Punctuation and symbols
|
|
||||||
expect(tokens).toContain("〜");
|
|
||||||
expect(tokens).toContain("〰");
|
|
||||||
expect(tokens).toContain("#");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
describe("Test measureText", () => {
|
||||||
describe("Test getContainerCoords", () => {
|
describe("Test getContainerCoords", () => {
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
ENV,
|
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} 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";
|
||||||
@@ -31,172 +31,6 @@ import {
|
|||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import type { ExtractSetType } from "../utility-types";
|
import type { ExtractSetType } from "../utility-types";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: ZWJ (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 do not understand the abbreviation (i.e. https://devina.io/redos-checker)
|
|
||||||
*/
|
|
||||||
const _EMOJI_CHAR =
|
|
||||||
/(\p{RI}\p{RI}|[\p{Extended_Pictographic}\p{Emoji_Presentation}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D(?:\p{RI}\p{RI}|[\p{Emoji}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*)/u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect a CJK char, though does not include every possible char used in CJK texts,
|
|
||||||
* such as symbols and punctuations.
|
|
||||||
*
|
|
||||||
* By default every CJK is a breaking point, though CJK has additional breaking points,
|
|
||||||
* including full width punctuations or symbols (Chinese and Japanese) and western punctuations (Korean).
|
|
||||||
*
|
|
||||||
* Additional CJK breaking point rules:
|
|
||||||
* - expect a break before (lookahead), but not after (negative lookbehind), i.e. "(" or "("
|
|
||||||
* - expect a break after (lookbehind), but not before (negative lookahead), i.e. ")" or ")"
|
|
||||||
* - expect a break always (lookahead and lookbehind), i.e. "〃"
|
|
||||||
*/
|
|
||||||
const _CJK_CHAR =
|
|
||||||
/\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Following characters break only with CJK, not with alphabetic characters.
|
|
||||||
* This is essential for Korean, as it uses alphabetic punctuation, but expects CJK-like breaking points.
|
|
||||||
*
|
|
||||||
* Hello((た)) → ["Hello", "((た))"]
|
|
||||||
* Hello((World)) → ["Hello((World))"]
|
|
||||||
*/
|
|
||||||
const _CJK_BREAK_NOT_AFTER_BUT_BEFORE = /<\(\[\{/u;
|
|
||||||
const _CJK_BREAK_NOT_BEFORE_BUT_AFTER = />\)\]\}.,:;\?!/u;
|
|
||||||
const _CJK_BREAK_ALWAYS = / 〃〜~〰#&*+-ー/=|¬ ̄¦/u;
|
|
||||||
const _CJK_SYMBOLS_AND_PUNCTUATION =
|
|
||||||
/()[]{}〈〉《》⦅⦆「」「」『』【】〖〗〔〕〘〙〚〛<>〝〞'〟・。゚゙,、.:;?!%ー/u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Following characters break with any character, even though are mostly used with CJK.
|
|
||||||
*
|
|
||||||
* Hello た。→ ["Hello", "た。"]
|
|
||||||
* ↑ DON'T BREAK "た。" (negative lookahead)
|
|
||||||
* Hello「た」 World → ["Hello", "「た」", "World"]
|
|
||||||
* ↑ DON'T BREAK "「た" (negative lookbehind)
|
|
||||||
* ↑ DON'T BREAK "た」"(negative lookahead)
|
|
||||||
* ↑ BREAK BEFORE "「" (lookahead)
|
|
||||||
* ↑ BREAK AFTER "」" (lookbehind)
|
|
||||||
*/
|
|
||||||
const _ANY_BREAK_NOT_AFTER_BUT_BEFORE = /([{〈《⦅「「『【〖〔〘〚<〝/u;
|
|
||||||
const _ANY_BREAK_NOT_BEFORE_BUT_AFTER =
|
|
||||||
/)]}〉》⦆」」』】〗〕〙〛>〞'〟・。゚゙,、.:;?!%±‥…\//u;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Natural breaking points for any grammars.
|
|
||||||
*
|
|
||||||
* Hello-world
|
|
||||||
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
|
|
||||||
* Hello world
|
|
||||||
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
|
|
||||||
*/
|
|
||||||
const _ANY_BREAK_AFTER = /-/u;
|
|
||||||
const _ANY_BREAK_ALWAYS = /\s/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 BREAK_LINE_REGEX_SIMPLE = new RegExp(
|
|
||||||
`${_EMOJI_CHAR.source}|([${_ANY_BREAK_ALWAYS.source}${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`,
|
|
||||||
"u",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hello World → ["Hello", " World"]
|
|
||||||
// ↑ BREAK BEFORE " "
|
|
||||||
// HelloたWorld → ["Hello", "たWorld"]
|
|
||||||
// ↑ BREAK BEFORE "た"
|
|
||||||
// Hello「World」→ ["Hello", "「World」"]
|
|
||||||
// ↑ BREAK BEFORE "「"
|
|
||||||
const getLookaheadBreakingPoints = () => {
|
|
||||||
const ANY_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_ANY_BREAK_ALWAYS.source}])`;
|
|
||||||
const CJK_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}]*[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}])`;
|
|
||||||
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hello World → ["Hello ", "World"]
|
|
||||||
// ↑ BREAK AFTER " "
|
|
||||||
// Hello-World → ["Hello-", "World"]
|
|
||||||
// ↑ BREAK AFTER "-"
|
|
||||||
// HelloたWorld → ["Helloた", "World"]
|
|
||||||
// ↑ BREAK AFTER "た"
|
|
||||||
//「Hello」World → ["「Hello」", "World"]
|
|
||||||
// ↑ BREAK AFTER "」"
|
|
||||||
const getLookbehindBreakingPoints = () => {
|
|
||||||
const ANY_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}])(?<=[${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`;
|
|
||||||
const CJK_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_AFTER.source}])(?<=[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}][${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}]*)`;
|
|
||||||
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Break a line based on the whitespaces, CJK / emoji chars and language specific breaking points,
|
|
||||||
* like hyphen for alphabetic and various full-width codepoints for CJK - especially Japanese, e.g.:
|
|
||||||
*
|
|
||||||
* "Hello 世界。🌎🗺" → ["Hello", " ", "世", "界。", "🌎", "🗺"]
|
|
||||||
* "Hello-world" → ["Hello-", "world"]
|
|
||||||
* "「Hello World」" → ["「Hello", " ", "World」"]
|
|
||||||
*/
|
|
||||||
const getBreakLineRegexAdvanced = () =>
|
|
||||||
new RegExp(
|
|
||||||
`${_EMOJI_CHAR.source}|${getLookaheadBreakingPoints().source}|${
|
|
||||||
getLookbehindBreakingPoints().source
|
|
||||||
}`,
|
|
||||||
"u",
|
|
||||||
);
|
|
||||||
|
|
||||||
let cachedBreakLineRegex: RegExp | undefined;
|
|
||||||
|
|
||||||
// Lazy-load for browsers that don't support "Lookbehind assertion"
|
|
||||||
const getBreakLineRegex = () => {
|
|
||||||
if (!cachedBreakLineRegex) {
|
|
||||||
try {
|
|
||||||
cachedBreakLineRegex = getBreakLineRegexAdvanced();
|
|
||||||
} catch {
|
|
||||||
cachedBreakLineRegex = BREAK_LINE_REGEX_SIMPLE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedBreakLineRegex;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CJK_REGEX = new RegExp(
|
|
||||||
`[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_CJK_SYMBOLS_AND_PUNCTUATION.source}]`,
|
|
||||||
"u",
|
|
||||||
);
|
|
||||||
|
|
||||||
const EMOJI_REGEX = new RegExp(`${_EMOJI_CHAR.source}`, "u");
|
|
||||||
|
|
||||||
export const containsCJK = (text: string) => {
|
|
||||||
return CJK_REGEX.test(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const containsEmoji = (text: string) => {
|
|
||||||
return EMOJI_REGEX.test(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
normalizeEOL(text)
|
normalizeEOL(text)
|
||||||
@@ -510,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,
|
||||||
@@ -575,164 +409,6 @@ export const getTextHeight = (
|
|||||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseTokens = (line: string) => {
|
|
||||||
const breakLineRegex = getBreakLineRegex();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapWord = (
|
|
||||||
word: string,
|
|
||||||
font: FontString,
|
|
||||||
maxWidth: number,
|
|
||||||
): Array<string> => {
|
|
||||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
|
||||||
if (EMOJI_REGEX.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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
lines.push(currentLine.trimEnd());
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
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");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const charWidth = (() => {
|
export const charWidth = (() => {
|
||||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import { wrapText, parseTokens } from "./textWrapping";
|
||||||
|
import type { FontString } from "./types";
|
||||||
|
|
||||||
|
describe("Test wrapText", () => {
|
||||||
|
// font is irrelevant as jsdom does not support FontFace API
|
||||||
|
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
|
||||||
|
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
|
||||||
|
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
|
||||||
|
|
||||||
|
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||||
|
const text = "Hello Excalidraw";
|
||||||
|
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||||
|
const res = wrapText(text, font, 100);
|
||||||
|
expect(res).toEqual(`Hello\nExcalidraw`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the text as is if max width is invalid", () => {
|
||||||
|
const text = "Hello Excalidraw";
|
||||||
|
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||||
|
expect(wrapText(text, font, -1)).toEqual(text);
|
||||||
|
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the text correctly when max width reached", () => {
|
||||||
|
const text = "Hello😀";
|
||||||
|
const maxWidth = 10;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not wrap number when wrapping line", () => {
|
||||||
|
const text = "don't wrap this number 99,100.99";
|
||||||
|
const maxWidth = 300;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("don't wrap this number\n99,100.99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim all trailing whitespaces", () => {
|
||||||
|
const text = "Hello ";
|
||||||
|
const maxWidth = 50;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim all but one trailing whitespaces", () => {
|
||||||
|
const text = "Hello ";
|
||||||
|
const maxWidth = 60;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hello ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
|
||||||
|
const text = " Hello World";
|
||||||
|
const maxWidth = 90;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(" Hello\nWorld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
|
||||||
|
const text = " Hello World ";
|
||||||
|
const maxWidth = 90;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(" Hello\nWorld ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim keep those whitespace that fit in the trailing line", () => {
|
||||||
|
const text = "Hello Wo rl d ";
|
||||||
|
const maxWidth = 100;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hello Wo\nrl d ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support multiple (multi-codepoint) emojis", () => {
|
||||||
|
const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
|
||||||
|
const maxWidth = 1;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wrap the text correctly when text contains hyphen", () => {
|
||||||
|
let text =
|
||||||
|
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||||
|
const res = wrapText(text, font, 110);
|
||||||
|
expect(res).toBe(
|
||||||
|
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
|
||||||
|
);
|
||||||
|
|
||||||
|
text = "Hello thereusing-now";
|
||||||
|
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support wrapping nested lists", () => {
|
||||||
|
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
|
||||||
|
|
||||||
|
const maxWidth = 100;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
|
||||||
|
|
||||||
|
const maxWidth2 = 50;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text is CJK", () => {
|
||||||
|
it("should break each CJK character when width is very small", () => {
|
||||||
|
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||||
|
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||||
|
const maxWidth = 10;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(
|
||||||
|
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break CJK text into longer segments when width is larger", () => {
|
||||||
|
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||||
|
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||||
|
const maxWidth = 30;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
|
||||||
|
// measureText is mocked, so it's not precisely what would happen in prod
|
||||||
|
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
|
||||||
|
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
|
||||||
|
|
||||||
|
const maxWidth = 150;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
|
||||||
|
|
||||||
|
const maxWidth2 = 50;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
|
||||||
|
|
||||||
|
const maxWidth3 = 30;
|
||||||
|
const res3 = wrapText(text, font, maxWidth3);
|
||||||
|
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break before and after a regular CJK character", () => {
|
||||||
|
const text = "HelloたWorld";
|
||||||
|
const maxWidth1 = 50;
|
||||||
|
const res1 = wrapText(text, font, maxWidth1);
|
||||||
|
expect(res1).toBe("Hello\nた\nWorld");
|
||||||
|
|
||||||
|
const maxWidth2 = 60;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe("Helloた\nWorld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break before and after certain CJK symbols", () => {
|
||||||
|
const text = "こんにちは〃世界";
|
||||||
|
const maxWidth1 = 50;
|
||||||
|
const res1 = wrapText(text, font, maxWidth1);
|
||||||
|
expect(res1).toBe("こんにちは\n〃世界");
|
||||||
|
|
||||||
|
const maxWidth2 = 60;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe("こんにちは〃\n世界");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break after, not before for certain CJK pairs", () => {
|
||||||
|
const text = "Hello た。";
|
||||||
|
const maxWidth = 70;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hello\nた。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break before, not after for certain CJK pairs", () => {
|
||||||
|
const text = "Hello「たWorld」";
|
||||||
|
const maxWidth = 60;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hello\n「た\nWorld」");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break after, not before for certain CJK character pairs", () => {
|
||||||
|
const text = "「Helloた」World";
|
||||||
|
const maxWidth = 70;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("「Hello\nた」World");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break Chinese sentences", () => {
|
||||||
|
const text = `中国你好!这是一个测试。
|
||||||
|
我们来看看:人民币¥1234「很贵」
|
||||||
|
(括号)、逗号,句号。空格 换行 全角符号…—`;
|
||||||
|
|
||||||
|
const maxWidth1 = 80;
|
||||||
|
const res1 = wrapText(text, font, maxWidth1);
|
||||||
|
expect(res1).toBe(`中国你好!这是一\n个测试。
|
||||||
|
我们来看看:人民\n币¥1234「很\n贵」
|
||||||
|
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
|
||||||
|
|
||||||
|
const maxWidth2 = 50;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe(`中国你好!\n这是一个测\n试。
|
||||||
|
我们来看\n看:人民币\n¥1234\n「很贵」
|
||||||
|
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break Japanese sentences", () => {
|
||||||
|
const text = `日本こんにちは!これはテストです。
|
||||||
|
見てみましょう:円¥1234「高い」
|
||||||
|
(括弧)、読点、句点。
|
||||||
|
空白 改行 全角記号…ー`;
|
||||||
|
|
||||||
|
const maxWidth1 = 80;
|
||||||
|
const res1 = wrapText(text, font, maxWidth1);
|
||||||
|
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
|
||||||
|
見てみましょ\nう:円¥1234\n「高い」
|
||||||
|
(括弧)、読\n点、句点。
|
||||||
|
空白 改行\n全角記号…ー`);
|
||||||
|
|
||||||
|
const maxWidth2 = 50;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
|
||||||
|
見てみ\nましょう:\n円\n¥1234\n「高い」
|
||||||
|
(括\n弧)、読\n点、句点。
|
||||||
|
空白\n改行 全角\n記号…ー`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break Korean sentences", () => {
|
||||||
|
const text = `한국 안녕하세요! 이것은 테스트입니다.
|
||||||
|
우리 보자: 원화₩1234「비싸다」
|
||||||
|
(괄호), 쉼표, 마침표.
|
||||||
|
공백 줄바꿈 전각기호…—`;
|
||||||
|
|
||||||
|
const maxWidth1 = 80;
|
||||||
|
const res1 = wrapText(text, font, maxWidth1);
|
||||||
|
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
|
||||||
|
우리 보자: 원\n화₩1234「비\n싸다」
|
||||||
|
(괄호), 쉼\n표, 마침표.
|
||||||
|
공백 줄바꿈 전\n각기호…—`);
|
||||||
|
|
||||||
|
const maxWidth2 = 60;
|
||||||
|
const res2 = wrapText(text, font, maxWidth2);
|
||||||
|
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
|
||||||
|
우리 보자:\n원화\n₩1234\n「비싸다」
|
||||||
|
(괄호),\n쉼표, 마침\n표.
|
||||||
|
공백 줄바꿈\n전각기호…—`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text contains leading whitespaces", () => {
|
||||||
|
const text = " \t Hello world";
|
||||||
|
|
||||||
|
it("should preserve leading whitespaces", () => {
|
||||||
|
const maxWidth = 120;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(" \t Hello\nworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break and collapse leading whitespaces when line breaks", () => {
|
||||||
|
const maxWidth = 60;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("\nHello\nworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should break and collapse leading whitespaces whe words break", () => {
|
||||||
|
const maxWidth = 30;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("\nHel\nlo\nwor\nld");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text contains trailing whitespaces", () => {
|
||||||
|
it("shouldn't add new lines for trailing spaces", () => {
|
||||||
|
const text = "Hello whats up ";
|
||||||
|
const maxWidth = 190;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore trailing whitespaces when line breaks", () => {
|
||||||
|
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||||
|
const maxWidth = 400;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not ignore trailing whitespaces when word breaks", () => {
|
||||||
|
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||||
|
const maxWidth = 300;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
|
||||||
|
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||||
|
const maxWidth = 180;
|
||||||
|
const res = wrapText(text, font, maxWidth);
|
||||||
|
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text doesn't contain new lines", () => {
|
||||||
|
const text = "Hello whats up";
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
desc: "break all words when width of each word is less than container width",
|
||||||
|
width: 70,
|
||||||
|
res: `Hello\nwhats\nup`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "break all characters when width of each character is less than container width",
|
||||||
|
width: 15,
|
||||||
|
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "break words as per the width",
|
||||||
|
|
||||||
|
width: 130,
|
||||||
|
res: `Hello whats\nup`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "fit the container",
|
||||||
|
|
||||||
|
width: 240,
|
||||||
|
res: "Hello whats up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "push the word if its equal to max width",
|
||||||
|
width: 50,
|
||||||
|
res: `Hello\nwhats\nup`,
|
||||||
|
},
|
||||||
|
].forEach((data) => {
|
||||||
|
it(`should ${data.desc}`, () => {
|
||||||
|
const res = wrapText(text, font, data.width);
|
||||||
|
expect(res).toEqual(data.res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text contain new lines", () => {
|
||||||
|
const text = `Hello\n whats up`;
|
||||||
|
[
|
||||||
|
{
|
||||||
|
desc: "break all words when width of each word is less than container width",
|
||||||
|
width: 70,
|
||||||
|
res: `Hello\n whats\nup`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "break all characters when width of each character is less than container width",
|
||||||
|
width: 15,
|
||||||
|
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "break words as per the width",
|
||||||
|
width: 140,
|
||||||
|
res: `Hello\n whats up`,
|
||||||
|
},
|
||||||
|
].forEach((data) => {
|
||||||
|
it(`should respect new lines and ${data.desc}`, () => {
|
||||||
|
const res = wrapText(text, font, data.width);
|
||||||
|
expect(res).toEqual(data.res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("When text is long", () => {
|
||||||
|
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||||
|
[
|
||||||
|
{
|
||||||
|
desc: "fit characters of long string as per container width",
|
||||||
|
width: 160,
|
||||||
|
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "fit characters of long string as per container width and break words as per the width",
|
||||||
|
|
||||||
|
width: 120,
|
||||||
|
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||||
|
|
||||||
|
width: 590,
|
||||||
|
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
|
||||||
|
},
|
||||||
|
].forEach((data) => {
|
||||||
|
it(`should ${data.desc}`, () => {
|
||||||
|
const res = wrapText(text, font, data.width);
|
||||||
|
expect(res).toEqual(data.res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Test parseTokens", () => {
|
||||||
|
it("should tokenize latin", () => {
|
||||||
|
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||||
|
|
||||||
|
expect(parseTokens(text)).toEqual([
|
||||||
|
"Excalidraw",
|
||||||
|
" ",
|
||||||
|
"is",
|
||||||
|
" ",
|
||||||
|
"a",
|
||||||
|
" ",
|
||||||
|
"virtual",
|
||||||
|
" ",
|
||||||
|
"collaborative",
|
||||||
|
" ",
|
||||||
|
"whiteboard",
|
||||||
|
]);
|
||||||
|
|
||||||
|
text =
|
||||||
|
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||||
|
expect(parseTokens(text)).toEqual([
|
||||||
|
"Wikipedia",
|
||||||
|
" ",
|
||||||
|
"is",
|
||||||
|
" ",
|
||||||
|
"hosted",
|
||||||
|
" ",
|
||||||
|
"by",
|
||||||
|
" ",
|
||||||
|
"Wikimedia-",
|
||||||
|
" ",
|
||||||
|
"Foundation,",
|
||||||
|
" ",
|
||||||
|
"a",
|
||||||
|
" ",
|
||||||
|
"non-",
|
||||||
|
"profit",
|
||||||
|
" ",
|
||||||
|
"organization",
|
||||||
|
" ",
|
||||||
|
"that",
|
||||||
|
" ",
|
||||||
|
"also",
|
||||||
|
" ",
|
||||||
|
"hosts",
|
||||||
|
" ",
|
||||||
|
"a",
|
||||||
|
" ",
|
||||||
|
"range-",
|
||||||
|
"of",
|
||||||
|
" ",
|
||||||
|
"other",
|
||||||
|
" ",
|
||||||
|
"projects",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not tokenize number", () => {
|
||||||
|
const text = "99,100.99";
|
||||||
|
const tokens = parseTokens(text);
|
||||||
|
expect(tokens).toEqual(["99,100.99"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should tokenize joined emojis", () => {
|
||||||
|
const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`;
|
||||||
|
const tokens = parseTokens(text);
|
||||||
|
|
||||||
|
expect(tokens).toEqual([
|
||||||
|
"😬",
|
||||||
|
"🌍",
|
||||||
|
"🗺",
|
||||||
|
"🔥",
|
||||||
|
"☂️",
|
||||||
|
"👩🏽🦰",
|
||||||
|
"👨👩👧👦",
|
||||||
|
"👩🏾🔬",
|
||||||
|
"🏳️🌈",
|
||||||
|
"🧔♀️",
|
||||||
|
"🧑🤝🧑",
|
||||||
|
"🙅🏽♂️",
|
||||||
|
"✅",
|
||||||
|
"0️⃣",
|
||||||
|
"🇨🇿",
|
||||||
|
"🦅",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should tokenize emojis mixed with mixed text", () => {
|
||||||
|
const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`;
|
||||||
|
const tokens = parseTokens(text);
|
||||||
|
|
||||||
|
expect(tokens).toEqual([
|
||||||
|
"😬",
|
||||||
|
"a",
|
||||||
|
"🌍",
|
||||||
|
"b",
|
||||||
|
"🗺",
|
||||||
|
"c",
|
||||||
|
"🔥",
|
||||||
|
"d",
|
||||||
|
"☂️",
|
||||||
|
"《",
|
||||||
|
"👩🏽🦰",
|
||||||
|
"》",
|
||||||
|
"👨👩👧👦",
|
||||||
|
"德",
|
||||||
|
"👩🏾🔬",
|
||||||
|
"こ",
|
||||||
|
"🏳️🌈",
|
||||||
|
"안",
|
||||||
|
"🧔♀️",
|
||||||
|
"g",
|
||||||
|
"🧑🤝🧑",
|
||||||
|
"h",
|
||||||
|
"🙅🏽♂️",
|
||||||
|
"e",
|
||||||
|
"✅",
|
||||||
|
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
|
||||||
|
"🇨🇿",
|
||||||
|
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
|
||||||
|
"🦅",
|
||||||
|
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should tokenize decomposed chars into their composed variants", () => {
|
||||||
|
// each input character is in a decomposed form
|
||||||
|
const text = "čでäぴέ다й한";
|
||||||
|
expect(text.normalize("NFC").length).toEqual(8);
|
||||||
|
expect(text).toEqual(text.normalize("NFD"));
|
||||||
|
|
||||||
|
const tokens = parseTokens(text);
|
||||||
|
expect(tokens.length).toEqual(8);
|
||||||
|
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should tokenize artificial CJK", () => {
|
||||||
|
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
|
||||||
|
// [
|
||||||
|
// '《道', '德', '經》', '醫-',
|
||||||
|
// '醫', 'こ', 'ん', 'に',
|
||||||
|
// 'ち', 'は', '世', '界!',
|
||||||
|
// '안', '녕', '하', '세',
|
||||||
|
// '요', '세', '계;', '요』,',
|
||||||
|
// '다.', '다...', '원/', '달',
|
||||||
|
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
|
||||||
|
// 'た…', '[Hello]', ' ', '\t',
|
||||||
|
// ' ', 'World?', 'ニ', 'ュ',
|
||||||
|
// 'ー', 'ヨ', 'ー', 'ク・',
|
||||||
|
// '¥3700.55', 'す。', '090-', '1234-',
|
||||||
|
// '5678', '¥1,000〜', '$5,000', '「素',
|
||||||
|
// '晴', 'ら', 'し', 'い!」',
|
||||||
|
// '〔重', '要〕', '#', '1:',
|
||||||
|
// 'Taro', '君', '30%', 'は、',
|
||||||
|
// '(た', 'な', 'ば', 'た)',
|
||||||
|
// '〰', '¥110±', '¥570', 'で',
|
||||||
|
// '20℃〜', '9:30〜', '10:00', '【一',
|
||||||
|
// '番】'
|
||||||
|
// ]
|
||||||
|
const tokens = parseTokens(text);
|
||||||
|
|
||||||
|
// Latin
|
||||||
|
expect(tokens).toContain("[[1]]");
|
||||||
|
expect(tokens).toContain("[Hello]");
|
||||||
|
expect(tokens).toContain("World?");
|
||||||
|
expect(tokens).toContain("Taro");
|
||||||
|
|
||||||
|
// Chinese
|
||||||
|
expect(tokens).toContain("《道");
|
||||||
|
expect(tokens).toContain("德");
|
||||||
|
expect(tokens).toContain("經》");
|
||||||
|
expect(tokens).toContain("醫-");
|
||||||
|
expect(tokens).toContain("醫");
|
||||||
|
|
||||||
|
// Japanese
|
||||||
|
expect(tokens).toContain("こ");
|
||||||
|
expect(tokens).toContain("ん");
|
||||||
|
expect(tokens).toContain("に");
|
||||||
|
expect(tokens).toContain("ち");
|
||||||
|
expect(tokens).toContain("は");
|
||||||
|
expect(tokens).toContain("世");
|
||||||
|
expect(tokens).toContain("ク・");
|
||||||
|
expect(tokens).toContain("界!");
|
||||||
|
expect(tokens).toContain("た…");
|
||||||
|
expect(tokens).toContain("す。");
|
||||||
|
expect(tokens).toContain("ュ");
|
||||||
|
expect(tokens).toContain("「素");
|
||||||
|
expect(tokens).toContain("晴");
|
||||||
|
expect(tokens).toContain("ら");
|
||||||
|
expect(tokens).toContain("し");
|
||||||
|
expect(tokens).toContain("い!」");
|
||||||
|
expect(tokens).toContain("君");
|
||||||
|
expect(tokens).toContain("は、");
|
||||||
|
expect(tokens).toContain("(た");
|
||||||
|
expect(tokens).toContain("な");
|
||||||
|
expect(tokens).toContain("ば");
|
||||||
|
expect(tokens).toContain("た)");
|
||||||
|
expect(tokens).toContain("で");
|
||||||
|
expect(tokens).toContain("【一");
|
||||||
|
expect(tokens).toContain("番】");
|
||||||
|
|
||||||
|
// Check for Korean
|
||||||
|
expect(tokens).toContain("안");
|
||||||
|
expect(tokens).toContain("녕");
|
||||||
|
expect(tokens).toContain("하");
|
||||||
|
expect(tokens).toContain("세");
|
||||||
|
expect(tokens).toContain("요");
|
||||||
|
expect(tokens).toContain("세");
|
||||||
|
expect(tokens).toContain("계;");
|
||||||
|
expect(tokens).toContain("요』,");
|
||||||
|
expect(tokens).toContain("다.");
|
||||||
|
expect(tokens).toContain("다...");
|
||||||
|
expect(tokens).toContain("원/");
|
||||||
|
expect(tokens).toContain("달");
|
||||||
|
expect(tokens).toContain("(((다)))");
|
||||||
|
expect(tokens).toContain("〚({((한))>)〛");
|
||||||
|
expect(tokens).toContain("(「た」)");
|
||||||
|
|
||||||
|
// Numbers and units
|
||||||
|
expect(tokens).toContain("¥3700.55");
|
||||||
|
expect(tokens).toContain("090-");
|
||||||
|
expect(tokens).toContain("1234-");
|
||||||
|
expect(tokens).toContain("5678");
|
||||||
|
expect(tokens).toContain("¥1,000〜");
|
||||||
|
expect(tokens).toContain("$5,000");
|
||||||
|
expect(tokens).toContain("1:");
|
||||||
|
expect(tokens).toContain("30%");
|
||||||
|
expect(tokens).toContain("¥110±");
|
||||||
|
expect(tokens).toContain("20℃〜");
|
||||||
|
expect(tokens).toContain("9:30〜");
|
||||||
|
expect(tokens).toContain("10:00");
|
||||||
|
|
||||||
|
// Punctuation and symbols
|
||||||
|
expect(tokens).toContain(" ");
|
||||||
|
expect(tokens).toContain("\t");
|
||||||
|
expect(tokens).toContain(" ");
|
||||||
|
expect(tokens).toContain("ニ");
|
||||||
|
expect(tokens).toContain("ー");
|
||||||
|
expect(tokens).toContain("ヨ");
|
||||||
|
expect(tokens).toContain("〰");
|
||||||
|
expect(tokens).toContain("#");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import { ENV } from "../constants";
|
||||||
|
import { charWidth, getLineWidth } from "./textElement";
|
||||||
|
import type { FontString } from "./types";
|
||||||
|
|
||||||
|
let cachedCjkRegex: RegExp | undefined;
|
||||||
|
let cachedLineBreakRegex: RegExp | undefined;
|
||||||
|
let cachedEmojiRegex: RegExp | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
|
||||||
|
*/
|
||||||
|
export const containsCJK = (text: string) => {
|
||||||
|
if (!cachedCjkRegex) {
|
||||||
|
cachedCjkRegex = Regex.class(...Object.values(CJK));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedCjkRegex.test(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineBreakRegex = () => {
|
||||||
|
if (!cachedLineBreakRegex) {
|
||||||
|
try {
|
||||||
|
cachedLineBreakRegex = getLineBreakRegexAdvanced();
|
||||||
|
} catch {
|
||||||
|
cachedLineBreakRegex = getLineBreakRegexSimple();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedLineBreakRegex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmojiRegex = () => {
|
||||||
|
if (!cachedEmojiRegex) {
|
||||||
|
cachedEmojiRegex = getEmojiRegexUnicode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedEmojiRegex;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common symbols used across different languages.
|
||||||
|
*/
|
||||||
|
const COMMON = {
|
||||||
|
/**
|
||||||
|
* Natural breaking points for any grammars.
|
||||||
|
*
|
||||||
|
* Hello world
|
||||||
|
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
|
||||||
|
* Hello-world
|
||||||
|
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
|
||||||
|
*/
|
||||||
|
WHITESPACE: /\s/u,
|
||||||
|
HYPHEN: /-/u,
|
||||||
|
/**
|
||||||
|
* Generally do not break, unless closed symbol is followed by an opening symbol.
|
||||||
|
*
|
||||||
|
* Also, western punctation is often used in modern Korean and expects to be treated
|
||||||
|
* similarly to the CJK opening and closing symbols.
|
||||||
|
*
|
||||||
|
* Hello(한글)→ ["Hello", "(한", "글)"]
|
||||||
|
* ↑ BREAK BEFORE "("
|
||||||
|
* ↑ BREAK AFTER ")"
|
||||||
|
*/
|
||||||
|
OPENING: /<\(\[\{/u,
|
||||||
|
CLOSING: />\)\]\}.,:;!\?…\//u,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters and symbols used in Chinese, Japanese and Korean.
|
||||||
|
*/
|
||||||
|
const CJK = {
|
||||||
|
/**
|
||||||
|
* Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
|
||||||
|
*
|
||||||
|
* Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
|
||||||
|
*/
|
||||||
|
CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u,
|
||||||
|
/**
|
||||||
|
* Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
|
||||||
|
* and creates pairs with neighboring characters.
|
||||||
|
*
|
||||||
|
* Hello た。→ ["Hello", "た。"]
|
||||||
|
* ↑ DON'T BREAK "た。"
|
||||||
|
* * Hello「た」 World → ["Hello", "「た」", "World"]
|
||||||
|
* ↑ DON'T BREAK "「た"
|
||||||
|
* ↑ DON'T BREAK "た"
|
||||||
|
* ↑ BREAK BEFORE "「"
|
||||||
|
* ↑ BREAK AFTER "」"
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line prettier/prettier
|
||||||
|
OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u,
|
||||||
|
CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u,
|
||||||
|
/**
|
||||||
|
* Currency symbols break before, not after
|
||||||
|
*
|
||||||
|
* Price¥100 → ["Price", "¥100"]
|
||||||
|
* ↑ BREAK BEFORE "¥"
|
||||||
|
*/
|
||||||
|
CURRENCY: /¥₩£¢$/u,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOJI = {
|
||||||
|
FLAG: /\p{RI}\p{RI}/u,
|
||||||
|
JOINER:
|
||||||
|
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
|
||||||
|
ZWJ: /\u200D/u,
|
||||||
|
ANY: /[\p{Emoji}]/u,
|
||||||
|
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
|
||||||
|
*
|
||||||
|
* Browser support as of 10/2024:
|
||||||
|
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
|
||||||
|
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
|
||||||
|
*
|
||||||
|
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
|
||||||
|
*/
|
||||||
|
const getLineBreakRegexSimple = () =>
|
||||||
|
Regex.or(
|
||||||
|
getEmojiRegex(),
|
||||||
|
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the line breaking rules based for alphabetic-based languages,
|
||||||
|
* Chinese, Japanese, Korean and Emojis.
|
||||||
|
*
|
||||||
|
* "Hello-world" → ["Hello-", "world"]
|
||||||
|
* "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
|
||||||
|
*/
|
||||||
|
const getLineBreakRegexAdvanced = () =>
|
||||||
|
Regex.or(
|
||||||
|
// Unicode-defined regex for (multi-codepoint) Emojis
|
||||||
|
getEmojiRegex(),
|
||||||
|
// Rules for whitespace and hyphen
|
||||||
|
Break.Before(COMMON.WHITESPACE).Build(),
|
||||||
|
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
|
||||||
|
// Rules for CJK (chars, symbols, currency)
|
||||||
|
Break.Before(CJK.CHAR, CJK.CURRENCY)
|
||||||
|
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
|
||||||
|
.Build(),
|
||||||
|
Break.After(CJK.CHAR)
|
||||||
|
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
|
||||||
|
.Build(),
|
||||||
|
// Rules for opening and closing punctuation
|
||||||
|
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
|
||||||
|
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
|
||||||
|
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches various emoji types.
|
||||||
|
*
|
||||||
|
* 1. basic emojis (😀, 🌍)
|
||||||
|
* 2. flags (🇨🇿)
|
||||||
|
* 3. multi-codepoint emojis:
|
||||||
|
* - skin tones (👍🏽)
|
||||||
|
* - variation selectors (☂️)
|
||||||
|
* - keycaps (1️⃣)
|
||||||
|
* - tag sequences (🏴)
|
||||||
|
* - emoji sequences (👨👩👧👦, 👩🚀, 🏳️🌈)
|
||||||
|
*
|
||||||
|
* Unicode points:
|
||||||
|
* - \uFE0F: presentation selector
|
||||||
|
* - \u20E3: enclosing keycap
|
||||||
|
* - \u200D: zero width joiner
|
||||||
|
* - \u{E0020}-\u{E007E}: tags
|
||||||
|
* - \u{E007F}: cancel tag
|
||||||
|
*
|
||||||
|
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
|
||||||
|
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
|
||||||
|
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
|
||||||
|
*/
|
||||||
|
const getEmojiRegexUnicode = () =>
|
||||||
|
Regex.group(
|
||||||
|
Regex.or(
|
||||||
|
EMOJI.FLAG,
|
||||||
|
Regex.and(
|
||||||
|
EMOJI.MOST,
|
||||||
|
EMOJI.JOINER,
|
||||||
|
Regex.build(
|
||||||
|
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex utilities for unicode character classes.
|
||||||
|
*/
|
||||||
|
const Regex = {
|
||||||
|
/**
|
||||||
|
* Builds a regex from a string.
|
||||||
|
*/
|
||||||
|
build: (regex: string): RegExp => new RegExp(regex, "u"),
|
||||||
|
/**
|
||||||
|
* Joins regexes into a single string.
|
||||||
|
*/
|
||||||
|
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
|
||||||
|
/**
|
||||||
|
* Joins regexes into a single regex as with "and" operator.
|
||||||
|
*/
|
||||||
|
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
|
||||||
|
/**
|
||||||
|
* Joins regexes into a single regex with "or" operator.
|
||||||
|
*/
|
||||||
|
or: (...regexes: RegExp[]): RegExp =>
|
||||||
|
Regex.build(regexes.map((x) => x.source).join("|")),
|
||||||
|
/**
|
||||||
|
* Puts regexes into a matching group.
|
||||||
|
*/
|
||||||
|
group: (...regexes: RegExp[]): RegExp =>
|
||||||
|
Regex.build(`(${Regex.join(...regexes)})`),
|
||||||
|
/**
|
||||||
|
* Puts regexes into a character class.
|
||||||
|
*/
|
||||||
|
class: (...regexes: RegExp[]): RegExp =>
|
||||||
|
Regex.build(`[${Regex.join(...regexes)}]`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable lookahead and lookbehind utilities for defining line break
|
||||||
|
* opportunities between pairs of character classes.
|
||||||
|
*/
|
||||||
|
const Break = {
|
||||||
|
/**
|
||||||
|
* Break on the given class of characters.
|
||||||
|
*/
|
||||||
|
On: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
return Regex.build(`([${joined}])`);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Break before the given class of characters.
|
||||||
|
*/
|
||||||
|
Before: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?=[${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"FollowedBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Break after the given class of characters.
|
||||||
|
*/
|
||||||
|
After: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?<=[${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"PreceededBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Break before one or multiple characters of the same class.
|
||||||
|
*/
|
||||||
|
BeforeMany: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"FollowedBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Break after one or multiple character from the same class.
|
||||||
|
*/
|
||||||
|
AfterMany: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"PreceededBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Do not break before the given class of characters.
|
||||||
|
*/
|
||||||
|
NotBefore: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?![${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"NotFollowedBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Do not break after the given class of characters.
|
||||||
|
*/
|
||||||
|
NotAfter: (...regexes: RegExp[]) => {
|
||||||
|
const joined = Regex.join(...regexes);
|
||||||
|
const builder = () => Regex.build(`(?<![${joined}])`);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"NotPrecededBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
Chain: (rootBuilder: () => RegExp) => ({
|
||||||
|
/**
|
||||||
|
* Build the root regex.
|
||||||
|
*/
|
||||||
|
Build: rootBuilder,
|
||||||
|
/**
|
||||||
|
* Specify additional class of characters that should precede the root regex.
|
||||||
|
*/
|
||||||
|
PreceededBy: (...regexes: RegExp[]) => {
|
||||||
|
const root = rootBuilder();
|
||||||
|
const preceeded = Break.After(...regexes).Build();
|
||||||
|
const builder = () => Regex.and(preceeded, root);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"PreceededBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Specify additional class of characters that should follow the root regex.
|
||||||
|
*/
|
||||||
|
FollowedBy: (...regexes: RegExp[]) => {
|
||||||
|
const root = rootBuilder();
|
||||||
|
const followed = Break.Before(...regexes).Build();
|
||||||
|
const builder = () => Regex.and(root, followed);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"FollowedBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Specify additional class of characters that should not precede the root regex.
|
||||||
|
*/
|
||||||
|
NotPrecededBy: (...regexes: RegExp[]) => {
|
||||||
|
const root = rootBuilder();
|
||||||
|
const notPreceeded = Break.NotAfter(...regexes).Build();
|
||||||
|
const builder = () => Regex.and(notPreceeded, root);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"NotPrecededBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Specify additional class of characters that should not follow the root regex.
|
||||||
|
*/
|
||||||
|
NotFollowedBy: (...regexes: RegExp[]) => {
|
||||||
|
const root = rootBuilder();
|
||||||
|
const notFollowed = Break.NotBefore(...regexes).Build();
|
||||||
|
const builder = () => Regex.and(root, notFollowed);
|
||||||
|
return Break.Chain(builder) as Omit<
|
||||||
|
ReturnType<typeof Break.Chain>,
|
||||||
|
"NotFollowedBy"
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||||
|
*/
|
||||||
|
export const parseTokens = (line: string) => {
|
||||||
|
const breakLineRegex = getLineBreakRegex();
|
||||||
|
|
||||||
|
// normalizing to single-codepoint composed chars due to canonical equivalence
|
||||||
|
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
|
||||||
|
// filtering due to multi-codepoint chars like 👨👩👧👦, 👩🏽🦰
|
||||||
|
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the original text into the lines based on the given width.
|
||||||
|
*/
|
||||||
|
export const wrapText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
maxWidth: number,
|
||||||
|
): string => {
|
||||||
|
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||||
|
// computation, we need to make sure we don't continue as we'll end up
|
||||||
|
// in an infinite loop
|
||||||
|
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: Array<string> = [];
|
||||||
|
const originalLines = text.split("\n");
|
||||||
|
|
||||||
|
for (const originalLine of originalLines) {
|
||||||
|
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||||
|
|
||||||
|
if (currentLineWidth <= maxWidth) {
|
||||||
|
lines.push(originalLine);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||||
|
lines.push(...wrappedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the original line into the lines based on the given width.
|
||||||
|
*/
|
||||||
|
const wrapLine = (
|
||||||
|
line: string,
|
||||||
|
font: FontString,
|
||||||
|
maxWidth: number,
|
||||||
|
): string[] => {
|
||||||
|
const lines: Array<string> = [];
|
||||||
|
const tokens = parseTokens(line);
|
||||||
|
const tokenIterator = tokens[Symbol.iterator]();
|
||||||
|
|
||||||
|
let currentLine = "";
|
||||||
|
let currentLineWidth = 0;
|
||||||
|
|
||||||
|
let iterator = tokenIterator.next();
|
||||||
|
|
||||||
|
while (!iterator.done) {
|
||||||
|
const token = iterator.value;
|
||||||
|
const testLine = currentLine + token;
|
||||||
|
|
||||||
|
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||||
|
const testLineWidth = isSingleCharacter(token)
|
||||||
|
? currentLineWidth + charWidth.calculate(token, font)
|
||||||
|
: getLineWidth(testLine, font, true);
|
||||||
|
|
||||||
|
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||||
|
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||||
|
currentLine = testLine;
|
||||||
|
currentLineWidth = testLineWidth;
|
||||||
|
iterator = tokenIterator.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
|
||||||
|
if (!currentLine) {
|
||||||
|
const wrappedWord = wrapWord(token, font, maxWidth);
|
||||||
|
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
|
||||||
|
const precedingLines = wrappedWord.slice(0, -1);
|
||||||
|
|
||||||
|
lines.push(...precedingLines);
|
||||||
|
|
||||||
|
// trailing line of the wrapped word might still be joined with next token/s
|
||||||
|
currentLine = trailingLine;
|
||||||
|
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||||
|
iterator = tokenIterator.next();
|
||||||
|
} else {
|
||||||
|
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||||
|
lines.push(currentLine.trimEnd());
|
||||||
|
|
||||||
|
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||||
|
currentLine = "";
|
||||||
|
currentLineWidth = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterator done, push the trailing line if exists
|
||||||
|
if (currentLine) {
|
||||||
|
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||||
|
lines.push(trailingLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the word into the lines based on the given width.
|
||||||
|
*/
|
||||||
|
const wrapWord = (
|
||||||
|
word: string,
|
||||||
|
font: FontString,
|
||||||
|
maxWidth: number,
|
||||||
|
): Array<string> => {
|
||||||
|
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||||
|
if (getEmojiRegex().test(word)) {
|
||||||
|
return [word];
|
||||||
|
}
|
||||||
|
|
||||||
|
satisfiesWordInvariant(word);
|
||||||
|
|
||||||
|
const lines: Array<string> = [];
|
||||||
|
const chars = Array.from(word);
|
||||||
|
|
||||||
|
let currentLine = "";
|
||||||
|
let currentLineWidth = 0;
|
||||||
|
|
||||||
|
for (const char of chars) {
|
||||||
|
const _charWidth = charWidth.calculate(char, font);
|
||||||
|
const testLineWidth = currentLineWidth + _charWidth;
|
||||||
|
|
||||||
|
if (testLineWidth <= maxWidth) {
|
||||||
|
currentLine = currentLine + char;
|
||||||
|
currentLineWidth = testLineWidth;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLine = char;
|
||||||
|
currentLineWidth = _charWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||||
|
*/
|
||||||
|
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||||
|
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||||
|
|
||||||
|
if (!shouldTrimWhitespaces) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensively default to `trimeEnd` in case the regex does not match
|
||||||
|
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
|
||||||
|
line,
|
||||||
|
line.trimEnd(),
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
||||||
|
|
||||||
|
for (const whitespace of Array.from(whitespaces)) {
|
||||||
|
const _charWidth = charWidth.calculate(whitespace, font);
|
||||||
|
const testLineWidth = trimmedLineWidth + _charWidth;
|
||||||
|
|
||||||
|
if (testLineWidth > maxWidth) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedLine = trimmedLine + whitespace;
|
||||||
|
trimmedLineWidth = testLineWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given string is a single character.
|
||||||
|
*
|
||||||
|
* Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰).
|
||||||
|
*/
|
||||||
|
const isSingleCharacter = (maybeSingleCharacter: string) => {
|
||||||
|
return (
|
||||||
|
maybeSingleCharacter.codePointAt(0) !== undefined &&
|
||||||
|
maybeSingleCharacter.codePointAt(1) === undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invariant for the word wrapping algorithm.
|
||||||
|
*/
|
||||||
|
const satisfiesWordInvariant = (word: string) => {
|
||||||
|
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||||
|
if (/\s/.test(word)) {
|
||||||
|
throw new Error("Word should not contain any whitespaces!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
|
import { CLASSES, POINTER_BUTTON } from "../constants";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -27,13 +27,13 @@ import {
|
|||||||
getTextWidth,
|
getTextWidth,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
wrapText,
|
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
actionDecreaseFontSize,
|
actionDecreaseFontSize,
|
||||||
actionIncreaseFontSize,
|
actionIncreaseFontSize,
|
||||||
@@ -245,11 +245,6 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
const font = getFontString(updatedTextElement);
|
const font = getFontString(updatedTextElement);
|
||||||
|
|
||||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
|
||||||
const padding = !isSafari
|
|
||||||
? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Make sure text editor height doesn't go beyond viewport
|
// Make sure text editor height doesn't go beyond viewport
|
||||||
const editorMaxHeight =
|
const editorMaxHeight =
|
||||||
(appState.height - viewportY) / appState.zoom.value;
|
(appState.height - viewportY) / appState.zoom.value;
|
||||||
@@ -259,7 +254,7 @@ export const textWysiwyg = ({
|
|||||||
lineHeight: updatedTextElement.lineHeight,
|
lineHeight: updatedTextElement.lineHeight,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
left: `${viewportX - padding}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
width,
|
width,
|
||||||
@@ -269,7 +264,6 @@ export const textWysiwyg = ({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
editorMaxHeight,
|
editorMaxHeight,
|
||||||
),
|
),
|
||||||
padding: `0 ${padding}px`,
|
|
||||||
textAlign,
|
textAlign,
|
||||||
verticalAlign,
|
verticalAlign,
|
||||||
color: updatedTextElement.strokeColor,
|
color: updatedTextElement.strokeColor,
|
||||||
@@ -310,6 +304,7 @@ export const textWysiwyg = ({
|
|||||||
minHeight: "1em",
|
minHeight: "1em",
|
||||||
backfaceVisibility: "hidden",
|
backfaceVisibility: "hidden",
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
border: 0,
|
border: 0,
|
||||||
outline: 0,
|
outline: 0,
|
||||||
resize: "none",
|
resize: "none",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class WorkerUrlNotDefinedError extends Error {
|
|||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkerInTheMainChunkError extends Error {
|
export class WorkerInTheMainChunkError extends Error {
|
||||||
public code;
|
public code;
|
||||||
constructor(
|
constructor(
|
||||||
@@ -61,3 +62,14 @@ export class WorkerInTheMainChunkError extends Error {
|
|||||||
this.code = code;
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import {
|
|||||||
FONT_FAMILY_FALLBACKS,
|
FONT_FAMILY_FALLBACKS,
|
||||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
isSafari,
|
||||||
|
getFontFamilyFallbacks,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { charWidth, getContainerElement } from "../element/textElement";
|
import { charWidth, getContainerElement } from "../element/textElement";
|
||||||
|
import { containsCJK } from "../element/textWrapping";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||||
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
|
import { ExcalidrawFontFace } from "./ExcalidrawFontFace";
|
||||||
|
|
||||||
import { CascadiaFontFaces } from "./Cascadia";
|
import { CascadiaFontFaces } from "./Cascadia";
|
||||||
@@ -73,6 +76,13 @@ export class Fonts {
|
|||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the font families for the given scene.
|
||||||
|
*/
|
||||||
|
public getSceneFamilies = () => {
|
||||||
|
return Fonts.getUniqueFamilies(this.scene.getNonDeletedElements());
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if we load a (new) font, it's likely that text elements using it have
|
* if we load a (new) font, it's likely that text elements using it have
|
||||||
* already been rendered using a fallback font. Thus, we want invalidate
|
* already been rendered using a fallback font. Thus, we want invalidate
|
||||||
@@ -81,7 +91,7 @@ export class Fonts {
|
|||||||
* Invalidates text elements and rerenders scene, provided that at least one
|
* Invalidates text elements and rerenders scene, provided that at least one
|
||||||
* of the supplied fontFaces has not already been processed.
|
* of the supplied fontFaces has not already been processed.
|
||||||
*/
|
*/
|
||||||
public onLoaded = (fontFaces: readonly FontFace[]) => {
|
public onLoaded = (fontFaces: readonly FontFace[]): void => {
|
||||||
// bail if all fonts with have been processed. We're checking just a
|
// bail if all fonts with have been processed. We're checking just a
|
||||||
// subset of the font properties (though it should be enough), so it
|
// subset of the font properties (though it should be enough), so it
|
||||||
// can technically bail on a false positive.
|
// can technically bail on a false positive.
|
||||||
@@ -127,12 +137,40 @@ export class Fonts {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for a given scene and trigger scene update.
|
* Load font faces for a given scene and trigger scene update.
|
||||||
|
*
|
||||||
|
* FontFaceSet loadingdone event we listen on may not always
|
||||||
|
* fire (looking at you Safari), so on init we manually load all
|
||||||
|
* fonts and rerender scene text elements once done.
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face
|
||||||
|
* with the unique characters per family in the scene,
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
*/
|
*/
|
||||||
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
||||||
const sceneFamilies = this.getSceneFamilies();
|
const sceneFamilies = this.getSceneFamilies();
|
||||||
const loaded = await Fonts.loadFontFaces(sceneFamilies);
|
const charsPerFamily = isSafari
|
||||||
this.onLoaded(loaded);
|
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||||
return loaded;
|
: undefined;
|
||||||
|
|
||||||
|
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face,
|
||||||
|
* with the unique characters per family in the elements
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
|
*/
|
||||||
|
public static loadElementsFonts = async (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): Promise<FontFace[]> => {
|
||||||
|
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
||||||
|
const charsPerFamily = isSafari
|
||||||
|
? Fonts.getCharsPerFamily(elements)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,17 +182,48 @@ export class Fonts {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
* Generate CSS @font-face declarations for the given elements.
|
||||||
*/
|
*/
|
||||||
public static loadElementsFonts = async (
|
public static async generateFontFaceDeclarations(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): Promise<FontFace[]> => {
|
) {
|
||||||
const fontFamilies = Fonts.getElementsFamilies(elements);
|
const families = Fonts.getUniqueFamilies(elements);
|
||||||
return await Fonts.loadFontFaces(fontFamilies);
|
const charsPerFamily = Fonts.getCharsPerFamily(elements);
|
||||||
};
|
|
||||||
|
// for simplicity, assuming we have just one family with the CJK handdrawn fallback
|
||||||
|
const familyWithCJK = families.find((x) =>
|
||||||
|
getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (familyWithCJK) {
|
||||||
|
const characters = Fonts.getCharacters(charsPerFamily, familyWithCJK);
|
||||||
|
|
||||||
|
if (containsCJK(characters)) {
|
||||||
|
const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT];
|
||||||
|
|
||||||
|
// adding the same characters to the CJK handrawn family
|
||||||
|
charsPerFamily[family] = new Set(characters);
|
||||||
|
|
||||||
|
// the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order
|
||||||
|
// so that they get overriden with the later defined font faces, i.e. in case they share some codepoints
|
||||||
|
families.unshift(FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.),
|
||||||
|
// instead go three requests at a time, in a controlled manner, without completely blocking the main thread
|
||||||
|
// and avoiding potential issues such as rate limits
|
||||||
|
const iterator = Fonts.fontFacesStylesGenerator(families, charsPerFamily);
|
||||||
|
const concurrency = 3;
|
||||||
|
const fontFaces = await new PromisePool(iterator, concurrency).all();
|
||||||
|
|
||||||
|
// dedup just in case (i.e. could be the same font faces with 0 glyphs)
|
||||||
|
return Array.from(new Set(fontFaces));
|
||||||
|
}
|
||||||
|
|
||||||
private static async loadFontFaces(
|
private static async loadFontFaces(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
) {
|
) {
|
||||||
// add all registered font faces into the `document.fonts` (if not added already)
|
// add all registered font faces into the `document.fonts` (if not added already)
|
||||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||||
@@ -170,35 +239,136 @@ export class Fonts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedFontFaces = await Promise.all(
|
// loading 10 font faces at a time, in a controlled manner
|
||||||
fontFamilies.map(async (fontFamily) => {
|
const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily);
|
||||||
const fontString = getFontString({
|
const concurrency = 10;
|
||||||
fontFamily,
|
const fontFaces = await new PromisePool(iterator, concurrency).all();
|
||||||
fontSize: 16,
|
return fontFaces.flat().filter(Boolean);
|
||||||
});
|
}
|
||||||
|
|
||||||
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
private static *fontFacesLoader(
|
||||||
if (!window.document.fonts.check(fontString)) {
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
|
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
||||||
|
for (const [index, fontFamily] of fontFamilies.entries()) {
|
||||||
|
const font = getFontString({
|
||||||
|
fontFamily,
|
||||||
|
fontSize: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
||||||
|
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
|
||||||
|
const text =
|
||||||
|
isSafari && charsPerFamily
|
||||||
|
? Fonts.getCharacters(charsPerFamily, fontFamily)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!window.document.fonts.check(font, text)) {
|
||||||
|
yield promiseTry(async () => {
|
||||||
try {
|
try {
|
||||||
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
|
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
|
||||||
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
|
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
|
||||||
return await window.document.fonts.load(fontString);
|
const fontFaces = await window.document.fonts.load(font, text);
|
||||||
|
|
||||||
|
return [index, fontFaces];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// don't let it all fail if just one font fails to load
|
// don't let it all fail if just one font fails to load
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to load font "${fontString}" from urls "${Fonts.registered
|
`Failed to load font "${font}" from urls "${Fonts.registered
|
||||||
.get(fontFamily)
|
.get(fontFamily)
|
||||||
?.fontFaces.map((x) => x.urls)}"`,
|
?.fontFaces.map((x) => x.urls)}"`,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
private static *fontFacesStylesGenerator(
|
||||||
}),
|
families: Array<number>,
|
||||||
);
|
charsPerFamily: Record<number, Set<string>>,
|
||||||
|
): Generator<Promise<void | readonly [number, string]>> {
|
||||||
|
for (const [familyIndex, family] of families.entries()) {
|
||||||
|
const { fontFaces, metadata } = Fonts.registered.get(family) ?? {};
|
||||||
|
|
||||||
return loadedFontFaces.flat().filter(Boolean) as FontFace[];
|
if (!Array.isArray(fontFaces)) {
|
||||||
|
console.error(
|
||||||
|
`Couldn't find registered fonts for font-family "${family}"`,
|
||||||
|
Fonts.registered,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata?.local) {
|
||||||
|
// don't inline local fonts
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fontFaceIndex, fontFace] of fontFaces.entries()) {
|
||||||
|
yield promiseTry(async () => {
|
||||||
|
try {
|
||||||
|
const characters = Fonts.getCharacters(charsPerFamily, family);
|
||||||
|
const fontFaceCSS = await fontFace.toCSS(characters);
|
||||||
|
|
||||||
|
if (!fontFaceCSS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// giving a buffer of 10K font faces per family
|
||||||
|
const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex;
|
||||||
|
const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const;
|
||||||
|
|
||||||
|
return fontFaceTuple;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new font.
|
||||||
|
*
|
||||||
|
* @param family font family
|
||||||
|
* @param metadata font metadata
|
||||||
|
* @param fontFacesDecriptors font faces descriptors
|
||||||
|
*/
|
||||||
|
private static register(
|
||||||
|
this:
|
||||||
|
| Fonts
|
||||||
|
| {
|
||||||
|
registered: Map<
|
||||||
|
number,
|
||||||
|
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
family: string,
|
||||||
|
metadata: FontMetadata,
|
||||||
|
...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
|
||||||
|
) {
|
||||||
|
// TODO: likely we will need to abandon number value in order to support custom fonts
|
||||||
|
const fontFamily =
|
||||||
|
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
|
||||||
|
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
|
||||||
|
|
||||||
|
const registeredFamily = this.registered.get(fontFamily);
|
||||||
|
|
||||||
|
if (!registeredFamily) {
|
||||||
|
this.registered.set(fontFamily, {
|
||||||
|
metadata,
|
||||||
|
fontFaces: fontFacesDecriptors.map(
|
||||||
|
({ uri, descriptors }) =>
|
||||||
|
new ExcalidrawFontFace(family, uri, descriptors),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.registered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,57 +418,9 @@ export class Fonts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new font.
|
* Get all the unique font families for the given elements.
|
||||||
*
|
|
||||||
* @param family font family
|
|
||||||
* @param metadata font metadata
|
|
||||||
* @param fontFacesDecriptors font faces descriptors
|
|
||||||
*/
|
*/
|
||||||
private static register(
|
private static getUniqueFamilies(
|
||||||
this:
|
|
||||||
| Fonts
|
|
||||||
| {
|
|
||||||
registered: Map<
|
|
||||||
number,
|
|
||||||
{ metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] }
|
|
||||||
>;
|
|
||||||
},
|
|
||||||
family: string,
|
|
||||||
metadata: FontMetadata,
|
|
||||||
...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[]
|
|
||||||
) {
|
|
||||||
// TODO: likely we will need to abandon number value in order to support custom fonts
|
|
||||||
const fontFamily =
|
|
||||||
FONT_FAMILY[family as keyof typeof FONT_FAMILY] ??
|
|
||||||
FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS];
|
|
||||||
|
|
||||||
const registeredFamily = this.registered.get(fontFamily);
|
|
||||||
|
|
||||||
if (!registeredFamily) {
|
|
||||||
this.registered.set(fontFamily, {
|
|
||||||
metadata,
|
|
||||||
fontFaces: fontFacesDecriptors.map(
|
|
||||||
({ uri, descriptors }) =>
|
|
||||||
new ExcalidrawFontFace(family, uri, descriptors),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.registered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all the font families for the given scene.
|
|
||||||
*/
|
|
||||||
public getSceneFamilies = () => {
|
|
||||||
return Fonts.getElementsFamilies(this.scene.getNonDeletedElements());
|
|
||||||
};
|
|
||||||
|
|
||||||
private static getAllFamilies() {
|
|
||||||
return Array.from(Fonts.registered.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getElementsFamilies(
|
|
||||||
elements: ReadonlyArray<ExcalidrawElement>,
|
elements: ReadonlyArray<ExcalidrawElement>,
|
||||||
): Array<ExcalidrawTextElement["fontFamily"]> {
|
): Array<ExcalidrawTextElement["fontFamily"]> {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
@@ -310,6 +432,51 @@ export class Fonts {
|
|||||||
}, new Set<number>()),
|
}, new Set<number>()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the unique characters per font family for the given scene.
|
||||||
|
*/
|
||||||
|
private static getCharsPerFamily(
|
||||||
|
elements: ReadonlyArray<ExcalidrawElement>,
|
||||||
|
): Record<number, Set<string>> {
|
||||||
|
const charsPerFamily: Record<number, Set<string>> = {};
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (!isTextElement(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gather unique codepoints only when inlining fonts
|
||||||
|
for (const char of element.originalText) {
|
||||||
|
if (!charsPerFamily[element.fontFamily]) {
|
||||||
|
charsPerFamily[element.fontFamily] = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
charsPerFamily[element.fontFamily].add(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return charsPerFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get characters for a given family.
|
||||||
|
*/
|
||||||
|
private static getCharacters(
|
||||||
|
charsPerFamily: Record<number, Set<string>>,
|
||||||
|
family: number,
|
||||||
|
) {
|
||||||
|
return charsPerFamily[family]
|
||||||
|
? Array.from(charsPerFamily[family]).join("")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered font families.
|
||||||
|
*/
|
||||||
|
private static getAllFamilies() {
|
||||||
|
return Array.from(Fonts.registered.keys());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { atom, useAtom } from "jotai";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { COLOR_PALETTE } from "../colors";
|
import { COLOR_PALETTE } from "../colors";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { exportToSvg } from "../../utils/export";
|
|
||||||
import type { LibraryItem } from "../types";
|
import type { LibraryItem } from "../types";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
|
|
||||||
export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
|
export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
|
||||||
|
|
||||||
@@ -11,14 +11,14 @@ export const libraryItemSvgsCache = atom<SvgCache>(new Map());
|
|||||||
|
|
||||||
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
||||||
return await exportToSvg({
|
return await exportToSvg({
|
||||||
elements,
|
data: {
|
||||||
appState: {
|
elements,
|
||||||
exportBackground: false,
|
appState: {
|
||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
},
|
},
|
||||||
files: null,
|
|
||||||
renderEmbeddables: false,
|
|
||||||
skipInliningFonts: true,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { exportToCanvas } from "./scene/export";
|
import { exportToCanvas } from "./scene/export";
|
||||||
import { getDefaultAppState } from "./appState";
|
import { getDefaultAppState } from "./appState";
|
||||||
|
import { COLOR_WHITE } from "./constants";
|
||||||
|
|
||||||
const { registerFont, createCanvas } = require("canvas");
|
const { registerFont, createCanvas } = require("canvas");
|
||||||
|
|
||||||
@@ -57,22 +58,21 @@ const elements = [
|
|||||||
registerFont("./public/Virgil.woff2", { family: "Virgil" });
|
registerFont("./public/Virgil.woff2", { family: "Virgil" });
|
||||||
registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
|
registerFont("./public/Cascadia.woff2", { family: "Cascadia" });
|
||||||
|
|
||||||
const canvas = exportToCanvas(
|
const canvas = exportToCanvas({
|
||||||
elements as any,
|
data: {
|
||||||
{
|
elements: elements as any,
|
||||||
...getDefaultAppState(),
|
appState: {
|
||||||
offsetTop: 0,
|
...getDefaultAppState(),
|
||||||
offsetLeft: 0,
|
width: 0,
|
||||||
width: 0,
|
height: 0,
|
||||||
height: 0,
|
},
|
||||||
|
files: {}, // files
|
||||||
},
|
},
|
||||||
{}, // files
|
config: {
|
||||||
{
|
canvasBackgroundColor: COLOR_WHITE,
|
||||||
exportBackground: true,
|
createCanvas,
|
||||||
viewBackgroundColor: "#ffffff",
|
|
||||||
},
|
},
|
||||||
createCanvas,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const out = fs.createWriteStream("test.png");
|
const out = fs.createWriteStream("test.png");
|
||||||
|
|||||||
@@ -225,13 +225,6 @@ export {
|
|||||||
|
|
||||||
export { reconcileElements } from "./data/reconcile";
|
export { reconcileElements } from "./data/reconcile";
|
||||||
|
|
||||||
export {
|
|
||||||
exportToCanvas,
|
|
||||||
exportToBlob,
|
|
||||||
exportToSvg,
|
|
||||||
exportToClipboard,
|
|
||||||
} from "../utils/export";
|
|
||||||
|
|
||||||
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
|
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
|
||||||
export {
|
export {
|
||||||
loadFromBlob,
|
loadFromBlob,
|
||||||
@@ -274,6 +267,13 @@ export { WelcomeScreen };
|
|||||||
export { LiveCollaborationTrigger };
|
export { LiveCollaborationTrigger };
|
||||||
export { Stats } from "./components/Stats";
|
export { Stats } from "./components/Stats";
|
||||||
|
|
||||||
|
export {
|
||||||
|
exportToCanvas,
|
||||||
|
exportToBlob,
|
||||||
|
exportToClipboard,
|
||||||
|
exportToSvg,
|
||||||
|
} from "./scene/export";
|
||||||
|
|
||||||
export { DefaultSidebar } from "./components/DefaultSidebar";
|
export { DefaultSidebar } from "./components/DefaultSidebar";
|
||||||
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
|
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
|
||||||
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
|
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
|
||||||
|
|||||||
@@ -58,11 +58,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "1.1.0",
|
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-popover": "1.0.3",
|
"@radix-ui/react-popover": "1.0.3",
|
||||||
"@radix-ui/react-tabs": "1.0.2",
|
"@radix-ui/react-tabs": "1.0.2",
|
||||||
"@tldraw/vec": "1.7.1",
|
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.29.1",
|
||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { StaticCanvasAppState, AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
|
||||||
import type { StaticCanvasRenderConfig } from "../scene/types";
|
import type { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
|
|
||||||
@@ -34,15 +34,16 @@ export const bootstrapCanvas = ({
|
|||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
theme,
|
theme,
|
||||||
isExporting,
|
isExporting,
|
||||||
viewBackgroundColor,
|
canvasBackgroundColor,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
scale: number;
|
scale: number;
|
||||||
normalizedWidth: number;
|
normalizedWidth: number;
|
||||||
normalizedHeight: number;
|
normalizedHeight: number;
|
||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
|
// static canvas only
|
||||||
isExporting?: StaticCanvasRenderConfig["isExporting"];
|
isExporting?: StaticCanvasRenderConfig["isExporting"];
|
||||||
viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
|
canvasBackgroundColor?: string | null;
|
||||||
}): CanvasRenderingContext2D => {
|
}): CanvasRenderingContext2D => {
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
@@ -54,17 +55,17 @@ export const bootstrapCanvas = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Paint background
|
// Paint background
|
||||||
if (typeof viewBackgroundColor === "string") {
|
if (typeof canvasBackgroundColor === "string") {
|
||||||
const hasTransparence =
|
const hasTransparence =
|
||||||
viewBackgroundColor === "transparent" ||
|
canvasBackgroundColor === "transparent" ||
|
||||||
viewBackgroundColor.length === 5 || // #RGBA
|
canvasBackgroundColor.length === 5 || // #RGBA
|
||||||
viewBackgroundColor.length === 9 || // #RRGGBBA
|
canvasBackgroundColor.length === 9 || // #RRGGBBA
|
||||||
/(hsla|rgba)\(/.test(viewBackgroundColor);
|
/(hsla|rgba)\(/.test(canvasBackgroundColor);
|
||||||
if (hasTransparence) {
|
if (hasTransparence) {
|
||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
context.save();
|
context.save();
|
||||||
context.fillStyle = viewBackgroundColor;
|
context.fillStyle = canvasBackgroundColor;
|
||||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
context.restore();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ const _renderStaticScene = ({
|
|||||||
normalizedHeight,
|
normalizedHeight,
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
isExporting,
|
isExporting,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
canvasBackgroundColor: renderConfig.canvasBackgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply zoom
|
// Apply zoom
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SVG_NS,
|
SVG_NS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { normalizeLink, toValidURL } from "../data/url";
|
import { normalizeLink, toValidURL } from "../data/url";
|
||||||
import { getElementAbsoluteCoords } from "../element";
|
import { getElementAbsoluteCoords, hashString } from "../element";
|
||||||
import {
|
import {
|
||||||
createPlaceholderEmbeddableLabel,
|
createPlaceholderEmbeddableLabel,
|
||||||
getEmbedLink,
|
getEmbedLink,
|
||||||
@@ -411,7 +411,25 @@ const renderElementToSvg = (
|
|||||||
const fileData =
|
const fileData =
|
||||||
isInitializedImageElement(element) && files[element.fileId];
|
isInitializedImageElement(element) && files[element.fileId];
|
||||||
if (fileData) {
|
if (fileData) {
|
||||||
const symbolId = `image-${fileData.id}`;
|
const { reuseImages = true } = renderConfig;
|
||||||
|
|
||||||
|
let symbolId = `image-${fileData.id}`;
|
||||||
|
|
||||||
|
let uncroppedWidth = element.width;
|
||||||
|
let uncroppedHeight = element.height;
|
||||||
|
if (element.crop) {
|
||||||
|
({ width: uncroppedWidth, height: uncroppedHeight } =
|
||||||
|
getUncroppedWidthAndHeight(element));
|
||||||
|
|
||||||
|
symbolId = `image-crop-${fileData.id}-${hashString(
|
||||||
|
`${uncroppedWidth}x${uncroppedHeight}`,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reuseImages) {
|
||||||
|
symbolId = `image-${element.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
let symbol = svgRoot.querySelector(`#${symbolId}`);
|
let symbol = svgRoot.querySelector(`#${symbolId}`);
|
||||||
if (!symbol) {
|
if (!symbol) {
|
||||||
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
|
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
|
||||||
@@ -421,18 +439,7 @@ const renderElementToSvg = (
|
|||||||
image.setAttribute("href", fileData.dataURL);
|
image.setAttribute("href", fileData.dataURL);
|
||||||
image.setAttribute("preserveAspectRatio", "none");
|
image.setAttribute("preserveAspectRatio", "none");
|
||||||
|
|
||||||
if (element.crop) {
|
if (element.crop || !reuseImages) {
|
||||||
const { width: uncroppedWidth, height: uncroppedHeight } =
|
|
||||||
getUncroppedWidthAndHeight(element);
|
|
||||||
|
|
||||||
symbol.setAttribute(
|
|
||||||
"viewBox",
|
|
||||||
`${
|
|
||||||
element.crop.x / (element.crop.naturalWidth / uncroppedWidth)
|
|
||||||
} ${
|
|
||||||
element.crop.y / (element.crop.naturalHeight / uncroppedHeight)
|
|
||||||
} ${width} ${height}`,
|
|
||||||
);
|
|
||||||
image.setAttribute("width", `${uncroppedWidth}`);
|
image.setAttribute("width", `${uncroppedWidth}`);
|
||||||
image.setAttribute("height", `${uncroppedHeight}`);
|
image.setAttribute("height", `${uncroppedHeight}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -456,8 +463,23 @@ const renderElementToSvg = (
|
|||||||
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
use.setAttribute("filter", IMAGE_INVERT_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
use.setAttribute("width", `${width}`);
|
let normalizedCropX = 0;
|
||||||
use.setAttribute("height", `${height}`);
|
let normalizedCropY = 0;
|
||||||
|
|
||||||
|
if (element.crop) {
|
||||||
|
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||||
|
getUncroppedWidthAndHeight(element);
|
||||||
|
normalizedCropX =
|
||||||
|
element.crop.x / (element.crop.naturalWidth / uncroppedWidth);
|
||||||
|
normalizedCropY =
|
||||||
|
element.crop.y / (element.crop.naturalHeight / uncroppedHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedCenterX = cx + normalizedCropX;
|
||||||
|
const adjustedCenterY = cy + normalizedCropY;
|
||||||
|
|
||||||
|
use.setAttribute("width", `${width + normalizedCropX}`);
|
||||||
|
use.setAttribute("height", `${height + normalizedCropY}`);
|
||||||
use.setAttribute("opacity", `${opacity}`);
|
use.setAttribute("opacity", `${opacity}`);
|
||||||
|
|
||||||
// We first apply `scale` transforms (horizontal/vertical mirroring)
|
// We first apply `scale` transforms (horizontal/vertical mirroring)
|
||||||
@@ -467,21 +489,43 @@ const renderElementToSvg = (
|
|||||||
// the transformations correctly (the transform-origin was not being
|
// the transformations correctly (the transform-origin was not being
|
||||||
// applied correctly).
|
// applied correctly).
|
||||||
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
|
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
|
||||||
const translateX = element.scale[0] !== 1 ? -width : 0;
|
|
||||||
const translateY = element.scale[1] !== 1 ? -height : 0;
|
|
||||||
use.setAttribute(
|
use.setAttribute(
|
||||||
"transform",
|
"transform",
|
||||||
`scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
|
`translate(${adjustedCenterX} ${adjustedCenterY}) scale(${
|
||||||
|
element.scale[0]
|
||||||
|
} ${
|
||||||
|
element.scale[1]
|
||||||
|
}) translate(${-adjustedCenterX} ${-adjustedCenterY})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
|
|
||||||
|
if (element.crop) {
|
||||||
|
const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
|
||||||
|
mask.setAttribute("id", `mask-image-crop-${element.id}`);
|
||||||
|
mask.setAttribute("fill", "#fff");
|
||||||
|
const maskRect = svgRoot.ownerDocument!.createElementNS(
|
||||||
|
SVG_NS,
|
||||||
|
"rect",
|
||||||
|
);
|
||||||
|
|
||||||
|
maskRect.setAttribute("x", `${normalizedCropX}`);
|
||||||
|
maskRect.setAttribute("y", `${normalizedCropY}`);
|
||||||
|
maskRect.setAttribute("width", `${width}`);
|
||||||
|
maskRect.setAttribute("height", `${height}`);
|
||||||
|
|
||||||
|
mask.appendChild(maskRect);
|
||||||
|
root.appendChild(mask);
|
||||||
|
g.setAttribute("mask", `url(#${mask.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
g.appendChild(use);
|
g.appendChild(use);
|
||||||
g.setAttribute(
|
g.setAttribute(
|
||||||
"transform",
|
"transform",
|
||||||
`translate(${offsetX || 0} ${
|
`translate(${offsetX - normalizedCropX} ${
|
||||||
offsetY || 0
|
offsetY - normalizedCropY
|
||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (element.roundness) {
|
if (element.roundness) {
|
||||||
|
|||||||
@@ -164,8 +164,10 @@ const getArrowheadShapes = (
|
|||||||
arrowhead: Arrowhead,
|
arrowhead: Arrowhead,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
options: Options,
|
options: Options,
|
||||||
canvasBackgroundColor: string,
|
canvasBackgroundColor: string | null,
|
||||||
) => {
|
) => {
|
||||||
|
canvasBackgroundColor = canvasBackgroundColor || "transparent";
|
||||||
|
|
||||||
const arrowheadPoints = getArrowheadPoints(
|
const arrowheadPoints = getArrowheadPoints(
|
||||||
element,
|
element,
|
||||||
shape,
|
shape,
|
||||||
@@ -293,7 +295,7 @@ export const _generateElementShape = (
|
|||||||
embedsValidationStatus,
|
embedsValidationStatus,
|
||||||
}: {
|
}: {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
canvasBackgroundColor: string;
|
canvasBackgroundColor: string | null;
|
||||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||||
},
|
},
|
||||||
): Drawable | Drawable[] | null => {
|
): Drawable | Drawable[] | null => {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { elementWithCanvasCache } from "../renderer/renderElement";
|
|||||||
import { _generateElementShape } from "./Shape";
|
import { _generateElementShape } from "./Shape";
|
||||||
import type { ElementShape, ElementShapes } from "./types";
|
import type { ElementShape, ElementShapes } from "./types";
|
||||||
import { COLOR_PALETTE } from "../colors";
|
import { COLOR_PALETTE } from "../colors";
|
||||||
import type { AppState, EmbedsValidationStatus } from "../types";
|
import type { EmbedsValidationStatus } from "../types";
|
||||||
|
import type { StaticCanvasRenderConfig } from "./types";
|
||||||
|
|
||||||
export class ShapeCache {
|
export class ShapeCache {
|
||||||
private static rg = new RoughGenerator();
|
private static rg = new RoughGenerator();
|
||||||
@@ -50,7 +51,7 @@ export class ShapeCache {
|
|||||||
element: T,
|
element: T,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: StaticCanvasRenderConfig["canvasBackgroundColor"];
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
} | null,
|
} | null,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
+612
-248
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ export type RenderableElementsMap = NonDeletedElementsMap &
|
|||||||
MakeBrand<"RenderableElementsMap">;
|
MakeBrand<"RenderableElementsMap">;
|
||||||
|
|
||||||
export type StaticCanvasRenderConfig = {
|
export type StaticCanvasRenderConfig = {
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
|
||||||
// extra options passed to the renderer
|
// extra options passed to the renderer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
imageCache: AppClassProperties["imageCache"];
|
imageCache: AppClassProperties["imageCache"];
|
||||||
@@ -32,6 +31,8 @@ export type StaticCanvasRenderConfig = {
|
|||||||
/** when exporting the behavior is slightly different (e.g. we can't use
|
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||||
CSS filters), and we disable render optimizations for best output */
|
CSS filters), and we disable render optimizations for best output */
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
|
/** null indicates transparent bg */
|
||||||
|
canvasBackgroundColor: string | null;
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
elementsPendingErasure: ElementsPendingErasure;
|
elementsPendingErasure: ElementsPendingErasure;
|
||||||
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
||||||
@@ -46,6 +47,13 @@ export type SVGRenderConfig = {
|
|||||||
frameRendering: AppState["frameRendering"];
|
frameRendering: AppState["frameRendering"];
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
/**
|
||||||
|
* whether to attempt to reuse images as much as possible through symbols
|
||||||
|
* (reduces SVG size, but may be incompoatible with some SVG renderers)
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
reuseImages: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InteractiveCanvasRenderConfig = {
|
export type InteractiveCanvasRenderConfig = {
|
||||||
@@ -74,6 +82,13 @@ export type StaticSceneRenderConfig = {
|
|||||||
elementsMap: RenderableElementsMap;
|
elementsMap: RenderableElementsMap;
|
||||||
allElementsMap: NonDeletedSceneElementsMap;
|
allElementsMap: NonDeletedSceneElementsMap;
|
||||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
/**
|
||||||
|
* canvas scale factor. Not related to zoom. In browsers, it's the
|
||||||
|
* devicePixelRatio. For export, it's the `appState.exportScale`
|
||||||
|
* (user setting) or whatever scale you want to use when exporting elsewhere.
|
||||||
|
*
|
||||||
|
* Bigger the scale, the more pixels (=quality).
|
||||||
|
*/
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: StaticCanvasAppState;
|
appState: StaticCanvasAppState;
|
||||||
renderConfig: StaticCanvasRenderConfig;
|
renderConfig: StaticCanvasRenderConfig;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active too
|
|||||||
B --> C{Let me think}
|
B --> C{Let me think}
|
||||||
C -->|One| D[Laptop]
|
C -->|One| D[Laptop]
|
||||||
C -->|Two| E[iPhone]
|
C -->|Two| E[iPhone]
|
||||||
C -->|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
|
C -->|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="300" height="0" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
|
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 25px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); padding: 0px 10px; text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ describe("Crop an image", () => {
|
|||||||
[-initialWidth / 3, 0],
|
[-initialWidth / 3, 0],
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(image.width).toBe(resizedWidth);
|
expect(image.width).toBeCloseTo(resizedWidth, 10);
|
||||||
expect(image.height).toBe(resizedHeight);
|
expect(image.height).toBeCloseTo(resizedHeight, 10);
|
||||||
|
|
||||||
// re-crop to initial state
|
// re-crop to initial state
|
||||||
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
|
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
|
||||||
@@ -315,20 +315,28 @@ describe("Cropping and other features", async () => {
|
|||||||
const widthToHeightRatio = image.width / image.height;
|
const widthToHeightRatio = image.width / image.height;
|
||||||
|
|
||||||
const canvas = await exportToCanvas({
|
const canvas = await exportToCanvas({
|
||||||
elements: [image],
|
data: {
|
||||||
appState: h.state,
|
elements: [image],
|
||||||
files: h.app.files,
|
appState: h.state,
|
||||||
exportPadding: 0,
|
files: h.app.files,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const exportedCanvasRatio = canvas.width / canvas.height;
|
const exportedCanvasRatio = canvas.width / canvas.height;
|
||||||
|
|
||||||
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
|
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: [image],
|
data: {
|
||||||
appState: h.state,
|
elements: [image],
|
||||||
files: h.app.files,
|
appState: h.state,
|
||||||
exportPadding: 0,
|
files: h.app.files,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const svgWidth = svg.getAttribute("width");
|
const svgWidth = svg.getAttribute("width");
|
||||||
const svgHeight = svg.getAttribute("height");
|
const svgHeight = svg.getAttribute("height");
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = await encodeSvgMetadata({
|
const encoded = encodeSvgMetadata({
|
||||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
|
const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
|
||||||
expect(decoded.elements).toEqual([
|
expect(decoded.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
]);
|
]);
|
||||||
@@ -163,7 +163,7 @@ describe("export", () => {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const svg = await exportToSvg(elements, appState, files);
|
const svg = await exportToSvg({ data: { elements, appState, files } });
|
||||||
|
|
||||||
const svgText = svg.outerHTML;
|
const svgText = svg.outerHTML;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { getDefaultAppState } from "../appState";
|
|||||||
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
import {
|
||||||
|
COLOR_CHARCOAL_BLACK,
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
MIME_TYPES,
|
||||||
|
} from "../constants";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import {
|
import {
|
||||||
@@ -77,7 +81,7 @@ const checkpoint = (name: string) => {
|
|||||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
const transparent = COLOR_PALETTE.transparent;
|
const transparent = COLOR_PALETTE.transparent;
|
||||||
const black = COLOR_PALETTE.black;
|
const black = COLOR_CHARCOAL_BLACK;
|
||||||
const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
|||||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
getBoundTextElementPosition,
|
getBoundTextElementPosition,
|
||||||
wrapText,
|
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import * as textElementUtils from "../element/textElement";
|
import * as textElementUtils from "../element/textElement";
|
||||||
@@ -29,6 +28,7 @@ import { vi } from "vitest";
|
|||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { GlobalPoint } from "../../math";
|
import type { GlobalPoint } from "../../math";
|
||||||
import { pointCenter, pointFrom } from "../../math";
|
import { pointCenter, pointFrom } from "../../math";
|
||||||
|
import { wrapText } from "../element/textWrapping";
|
||||||
|
|
||||||
const renderInteractiveScene = vi.spyOn(
|
const renderInteractiveScene = vi.spyOn(
|
||||||
InteractiveCanvas,
|
InteractiveCanvas,
|
||||||
@@ -1235,8 +1235,7 @@ describe("Test Linear Elements", () => {
|
|||||||
mouse.downAt(rect.x, rect.y);
|
mouse.downAt(rect.x, rect.y);
|
||||||
mouse.moveTo(200, 0);
|
mouse.moveTo(200, 0);
|
||||||
mouse.upAt(200, 0);
|
mouse.upAt(200, 0);
|
||||||
|
expect(arrow.width).toBe(200);
|
||||||
expect(arrow.width).toBe(205);
|
|
||||||
expect(rect.x).toBe(200);
|
expect(rect.x).toBe(200);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -882,11 +882,11 @@ describe("multiple selection", () => {
|
|||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
expect(leftBoundArrow.width).toBeCloseTo(137.5, 0);
|
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||||
expect(leftBoundArrow.angle).toEqual(0);
|
expect(leftBoundArrow.angle).toEqual(0);
|
||||||
expect(leftBoundArrow.startBinding).toBeNull();
|
expect(leftBoundArrow.startBinding).toBeNull();
|
||||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352);
|
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||||
leftArrowBinding.elementId,
|
leftArrowBinding.elementId,
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,11 @@ export type BinaryFileData = {
|
|||||||
* Epoch timestamp in milliseconds.
|
* Epoch timestamp in milliseconds.
|
||||||
*/
|
*/
|
||||||
lastRetrieved?: number;
|
lastRetrieved?: number;
|
||||||
|
/**
|
||||||
|
* indicates the version of the file. This can be used to determine whether
|
||||||
|
* the file dataURL has changed e.g. as part of restore due to schema update.
|
||||||
|
*/
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
||||||
@@ -168,8 +173,6 @@ type _CommonCanvasAppState = {
|
|||||||
export type StaticCanvasAppState = Readonly<
|
export type StaticCanvasAppState = Readonly<
|
||||||
_CommonCanvasAppState & {
|
_CommonCanvasAppState & {
|
||||||
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
||||||
/** null indicates transparent bg */
|
|
||||||
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
|
|
||||||
exportScale: AppState["exportScale"];
|
exportScale: AppState["exportScale"];
|
||||||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||||||
gridSize: AppState["gridSize"];
|
gridSize: AppState["gridSize"];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Pool from "es6-promise-pool";
|
import Pool from "es6-promise-pool";
|
||||||
import { average } from "../math";
|
import { average } from "../math";
|
||||||
import { COLOR_PALETTE } from "./colors";
|
|
||||||
import type { EVENT } from "./constants";
|
import type { EVENT } from "./constants";
|
||||||
import {
|
import {
|
||||||
|
COLOR_TRANSPARENT,
|
||||||
DEFAULT_VERSION,
|
DEFAULT_VERSION,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
@@ -536,11 +536,7 @@ export const findLastIndex = <T>(
|
|||||||
export const isTransparent = (color: string) => {
|
export const isTransparent = (color: string) => {
|
||||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||||
return (
|
return isRGBTransparent || isRRGGBBTransparent || color === COLOR_TRANSPARENT;
|
||||||
isRGBTransparent ||
|
|
||||||
isRRGGBBTransparent ||
|
|
||||||
color === COLOR_PALETTE.transparent
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvablePromise<T> = Promise<T> & {
|
export type ResolvablePromise<T> = Promise<T> & {
|
||||||
@@ -1225,3 +1221,18 @@ export class PromisePool<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const pick = <
|
||||||
|
R extends Record<string, any>,
|
||||||
|
K extends readonly (keyof R)[],
|
||||||
|
>(
|
||||||
|
source: R,
|
||||||
|
keys: K,
|
||||||
|
) => {
|
||||||
|
return keys.reduce((acc, key: K[number]) => {
|
||||||
|
if (key in source) {
|
||||||
|
acc[key] = source[key];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import * as utils from ".";
|
|
||||||
import { diagramFactory } from "../excalidraw/tests/fixtures/diagramFixture";
|
|
||||||
import { vi } from "vitest";
|
|
||||||
import * as mockedSceneExportUtils from "../excalidraw/scene/export";
|
|
||||||
|
|
||||||
import { MIME_TYPES } from "../excalidraw/constants";
|
|
||||||
|
|
||||||
const exportToSvgSpy = vi.spyOn(mockedSceneExportUtils, "exportToSvg");
|
|
||||||
|
|
||||||
describe("exportToCanvas", async () => {
|
|
||||||
const EXPORT_PADDING = 10;
|
|
||||||
|
|
||||||
it("with default arguments", async () => {
|
|
||||||
const canvas = await utils.exportToCanvas({
|
|
||||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(canvas.width).toBe(100 + 2 * EXPORT_PADDING);
|
|
||||||
expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when custom width and height", async () => {
|
|
||||||
const canvas = await utils.exportToCanvas({
|
|
||||||
...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
|
|
||||||
getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(canvas.width).toBe(200);
|
|
||||||
expect(canvas.height).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("exportToBlob", async () => {
|
|
||||||
describe("mime type", () => {
|
|
||||||
it("should change image/jpg to image/jpeg", async () => {
|
|
||||||
const blob = await utils.exportToBlob({
|
|
||||||
...diagramFactory(),
|
|
||||||
getDimensions: (width, height) => ({ width, height, scale: 1 }),
|
|
||||||
// testing typo in MIME type (jpg → jpeg)
|
|
||||||
mimeType: "image/jpg",
|
|
||||||
appState: {
|
|
||||||
exportBackground: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(blob?.type).toBe(MIME_TYPES.jpg);
|
|
||||||
});
|
|
||||||
it("should default to image/png", async () => {
|
|
||||||
const blob = await utils.exportToBlob({
|
|
||||||
...diagramFactory(),
|
|
||||||
});
|
|
||||||
expect(blob?.type).toBe(MIME_TYPES.png);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should warn when using quality with image/png", async () => {
|
|
||||||
const consoleSpy = vi
|
|
||||||
.spyOn(console, "warn")
|
|
||||||
.mockImplementationOnce(() => void 0);
|
|
||||||
await utils.exportToBlob({
|
|
||||||
...diagramFactory(),
|
|
||||||
mimeType: MIME_TYPES.png,
|
|
||||||
quality: 1,
|
|
||||||
});
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("exportToSvg", () => {
|
|
||||||
const passedElements = () => exportToSvgSpy.mock.calls[0][0];
|
|
||||||
const passedOptions = () => exportToSvgSpy.mock.calls[0][1];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("with default arguments", async () => {
|
|
||||||
await utils.exportToSvg({
|
|
||||||
...diagramFactory({
|
|
||||||
overrides: { appState: void 0 },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const passedOptionsWhenDefault = {
|
|
||||||
...passedOptions(),
|
|
||||||
// To avoid varying snapshots
|
|
||||||
name: "name",
|
|
||||||
};
|
|
||||||
expect(passedElements().length).toBe(3);
|
|
||||||
expect(passedOptionsWhenDefault).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
// FIXME the utils.exportToSvg no longer filters out deleted elements.
|
|
||||||
// It's already supposed to be passed non-deleted elements by we're not
|
|
||||||
// type-checking for it correctly.
|
|
||||||
it.skip("with deleted elements", async () => {
|
|
||||||
await utils.exportToSvg({
|
|
||||||
...diagramFactory({
|
|
||||||
overrides: { appState: void 0 },
|
|
||||||
elementOverrides: { isDeleted: true },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(passedElements().length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("with exportPadding", async () => {
|
|
||||||
await utils.exportToSvg({
|
|
||||||
...diagramFactory({ overrides: { appState: { name: "diagram name" } } }),
|
|
||||||
exportPadding: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(passedElements().length).toBe(3);
|
|
||||||
expect(passedOptions()).toEqual(
|
|
||||||
expect.objectContaining({ exportPadding: 0 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("with exportEmbedScene", async () => {
|
|
||||||
await utils.exportToSvg({
|
|
||||||
...diagramFactory({
|
|
||||||
overrides: {
|
|
||||||
appState: { name: "diagram name", exportEmbedScene: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(passedElements().length).toBe(3);
|
|
||||||
expect(passedOptions().exportEmbedScene).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import {
|
|
||||||
exportToCanvas as _exportToCanvas,
|
|
||||||
exportToSvg as _exportToSvg,
|
|
||||||
} from "../excalidraw/scene/export";
|
|
||||||
import { getDefaultAppState } from "../excalidraw/appState";
|
|
||||||
import type { AppState, BinaryFiles } from "../excalidraw/types";
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawFrameLikeElement,
|
|
||||||
NonDeleted,
|
|
||||||
} from "../excalidraw/element/types";
|
|
||||||
import { restore } from "../excalidraw/data/restore";
|
|
||||||
import { MIME_TYPES } from "../excalidraw/constants";
|
|
||||||
import { encodePngMetadata } from "../excalidraw/data/image";
|
|
||||||
import { serializeAsJSON } from "../excalidraw/data/json";
|
|
||||||
import {
|
|
||||||
copyBlobToClipboardAsPng,
|
|
||||||
copyTextToSystemClipboard,
|
|
||||||
copyToClipboard,
|
|
||||||
} from "../excalidraw/clipboard";
|
|
||||||
|
|
||||||
export { MIME_TYPES };
|
|
||||||
|
|
||||||
type ExportOpts = {
|
|
||||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
|
||||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
|
||||||
files: BinaryFiles | null;
|
|
||||||
maxWidthOrHeight?: number;
|
|
||||||
exportingFrame?: ExcalidrawFrameLikeElement | null;
|
|
||||||
getDimensions?: (
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
) => { width: number; height: number; scale?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToCanvas = ({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
files,
|
|
||||||
maxWidthOrHeight,
|
|
||||||
getDimensions,
|
|
||||||
exportPadding,
|
|
||||||
exportingFrame,
|
|
||||||
}: ExportOpts & {
|
|
||||||
exportPadding?: number;
|
|
||||||
}) => {
|
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
|
||||||
{ elements, appState },
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
|
||||||
return _exportToCanvas(
|
|
||||||
restoredElements,
|
|
||||||
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
|
|
||||||
files || {},
|
|
||||||
{ exportBackground, exportPadding, viewBackgroundColor, exportingFrame },
|
|
||||||
(width: number, height: number) => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
|
|
||||||
if (maxWidthOrHeight) {
|
|
||||||
if (typeof getDimensions === "function") {
|
|
||||||
console.warn(
|
|
||||||
"`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max(width, height);
|
|
||||||
|
|
||||||
// if content is less then maxWidthOrHeight, fallback on supplied scale
|
|
||||||
const scale =
|
|
||||||
maxWidthOrHeight < max
|
|
||||||
? maxWidthOrHeight / max
|
|
||||||
: appState?.exportScale ?? 1;
|
|
||||||
|
|
||||||
canvas.width = width * scale;
|
|
||||||
canvas.height = height * scale;
|
|
||||||
|
|
||||||
return {
|
|
||||||
canvas,
|
|
||||||
scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret = getDimensions?.(width, height) || { width, height };
|
|
||||||
|
|
||||||
canvas.width = ret.width;
|
|
||||||
canvas.height = ret.height;
|
|
||||||
|
|
||||||
return {
|
|
||||||
canvas,
|
|
||||||
scale: ret.scale ?? 1,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToBlob = async (
|
|
||||||
opts: ExportOpts & {
|
|
||||||
mimeType?: string;
|
|
||||||
quality?: number;
|
|
||||||
exportPadding?: number;
|
|
||||||
},
|
|
||||||
): Promise<Blob> => {
|
|
||||||
let { mimeType = MIME_TYPES.png, quality } = opts;
|
|
||||||
|
|
||||||
if (mimeType === MIME_TYPES.png && typeof quality === "number") {
|
|
||||||
console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// typo in MIME type (should be "jpeg")
|
|
||||||
if (mimeType === "image/jpg") {
|
|
||||||
mimeType = MIME_TYPES.jpg;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType === MIME_TYPES.jpg && !opts.appState?.exportBackground) {
|
|
||||||
console.warn(
|
|
||||||
`Defaulting "exportBackground" to "true" for "${MIME_TYPES.jpg}" mimeType`,
|
|
||||||
);
|
|
||||||
opts = {
|
|
||||||
...opts,
|
|
||||||
appState: { ...opts.appState, exportBackground: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = await exportToCanvas(opts);
|
|
||||||
|
|
||||||
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
canvas.toBlob(
|
|
||||||
async (blob) => {
|
|
||||||
if (!blob) {
|
|
||||||
return reject(new Error("couldn't export to blob"));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
blob &&
|
|
||||||
mimeType === MIME_TYPES.png &&
|
|
||||||
opts.appState?.exportEmbedScene
|
|
||||||
) {
|
|
||||||
blob = await encodePngMetadata({
|
|
||||||
blob,
|
|
||||||
metadata: serializeAsJSON(
|
|
||||||
// NOTE as long as we're using the Scene hack, we need to ensure
|
|
||||||
// we pass the original, uncloned elements when serializing
|
|
||||||
// so that we keep ids stable
|
|
||||||
opts.elements,
|
|
||||||
opts.appState,
|
|
||||||
opts.files || {},
|
|
||||||
"local",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
resolve(blob);
|
|
||||||
},
|
|
||||||
mimeType,
|
|
||||||
quality,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToSvg = async ({
|
|
||||||
elements,
|
|
||||||
appState = getDefaultAppState(),
|
|
||||||
files = {},
|
|
||||||
exportPadding,
|
|
||||||
renderEmbeddables,
|
|
||||||
exportingFrame,
|
|
||||||
skipInliningFonts,
|
|
||||||
}: Omit<ExportOpts, "getDimensions"> & {
|
|
||||||
exportPadding?: number;
|
|
||||||
renderEmbeddables?: boolean;
|
|
||||||
skipInliningFonts?: true;
|
|
||||||
}): Promise<SVGSVGElement> => {
|
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
|
||||||
{ elements, appState },
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportAppState = {
|
|
||||||
...restoredAppState,
|
|
||||||
exportPadding,
|
|
||||||
};
|
|
||||||
|
|
||||||
return _exportToSvg(restoredElements, exportAppState, files, {
|
|
||||||
exportingFrame,
|
|
||||||
renderEmbeddables,
|
|
||||||
skipInliningFonts,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportToClipboard = async (
|
|
||||||
opts: ExportOpts & {
|
|
||||||
mimeType?: string;
|
|
||||||
quality?: number;
|
|
||||||
type: "png" | "svg" | "json";
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
if (opts.type === "svg") {
|
|
||||||
const svg = await exportToSvg(opts);
|
|
||||||
await copyTextToSystemClipboard(svg.outerHTML);
|
|
||||||
} else if (opts.type === "png") {
|
|
||||||
await copyBlobToClipboardAsPng(exportToBlob(opts));
|
|
||||||
} else if (opts.type === "json") {
|
|
||||||
await copyToClipboard(opts.elements, opts.files);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid export type");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./export";
|
|
||||||
export * from "./withinBounds";
|
export * from "./withinBounds";
|
||||||
export * from "./bbox";
|
export * from "./bbox";
|
||||||
export { getCommonBounds } from "../excalidraw/element/bounds";
|
export { getCommonBounds } from "../excalidraw/element/bounds";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
|
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
|
||||||
import type { ImportedDataState } from "../excalidraw/data/types";
|
import type { ImportedDataState } from "../excalidraw/data/types";
|
||||||
import * as utils from "../utils";
|
import { exportToBlob, exportToSvg } from "../excalidraw/scene/export";
|
||||||
import { API } from "../excalidraw/tests/helpers/api";
|
import { API } from "../excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
// NOTE this test file is using the actual API, unmocked. Hence splitting it
|
// NOTE this test file is using the actual API, unmocked. Hence splitting it
|
||||||
@@ -15,19 +15,22 @@ describe("embedding scene data", () => {
|
|||||||
|
|
||||||
const sourceElements = [rectangle, ellipse];
|
const sourceElements = [rectangle, ellipse];
|
||||||
|
|
||||||
const svgNode = await utils.exportToSvg({
|
const svgNode = await exportToSvg({
|
||||||
elements: sourceElements,
|
data: {
|
||||||
appState: {
|
elements: sourceElements,
|
||||||
viewBackgroundColor: "#ffffff",
|
appState: {
|
||||||
gridModeEnabled: false,
|
viewBackgroundColor: "#ffffff",
|
||||||
exportEmbedScene: true,
|
gridModeEnabled: false,
|
||||||
|
exportEmbedScene: true,
|
||||||
|
exportBackground: true,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
},
|
},
|
||||||
files: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const svg = svgNode.outerHTML;
|
const svg = svgNode.outerHTML;
|
||||||
|
|
||||||
const parsedString = await decodeSvgMetadata({ svg });
|
const parsedString = decodeSvgMetadata({ svg });
|
||||||
const importedData: ImportedDataState = JSON.parse(parsedString);
|
const importedData: ImportedDataState = JSON.parse(parsedString);
|
||||||
|
|
||||||
expect(sourceElements.map((x) => x.id)).toEqual(
|
expect(sourceElements.map((x) => x.id)).toEqual(
|
||||||
@@ -45,15 +48,19 @@ describe("embedding scene data", () => {
|
|||||||
|
|
||||||
const sourceElements = [rectangle, ellipse];
|
const sourceElements = [rectangle, ellipse];
|
||||||
|
|
||||||
const blob = await utils.exportToBlob({
|
const blob = await exportToBlob({
|
||||||
mimeType: "image/png",
|
data: {
|
||||||
elements: sourceElements,
|
elements: sourceElements,
|
||||||
appState: {
|
appState: {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
gridModeEnabled: false,
|
gridModeEnabled: false,
|
||||||
exportEmbedScene: true,
|
exportEmbedScene: true,
|
||||||
|
},
|
||||||
|
files: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
mimeType: "image/png",
|
||||||
},
|
},
|
||||||
files: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedString = await decodePngMetadata(blob);
|
const parsedString = await decodePngMetadata(blob);
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#000000",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1891,13 +1891,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||||
|
|
||||||
"@excalidraw/mermaid-to-excalidraw@1.1.0":
|
"@excalidraw/mermaid-to-excalidraw@1.1.2":
|
||||||
version "1.1.0"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814"
|
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22"
|
||||||
integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg==
|
integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@excalidraw/markdown-to-text" "0.1.2"
|
"@excalidraw/markdown-to-text" "0.1.2"
|
||||||
mermaid "10.9.0"
|
mermaid "10.9.3"
|
||||||
nanoid "4.0.2"
|
nanoid "4.0.2"
|
||||||
|
|
||||||
"@excalidraw/prettier-config@1.0.2":
|
"@excalidraw/prettier-config@1.0.2":
|
||||||
@@ -3068,11 +3068,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.12.5"
|
"@babel/runtime" "^7.12.5"
|
||||||
|
|
||||||
"@tldraw/vec@1.7.1":
|
|
||||||
version "1.7.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@tldraw/vec/-/vec-1.7.1.tgz#5bfac9a56e11ad890cbd1c620293d7fcb23bf1ea"
|
|
||||||
integrity sha512-qM6Z9RvkLFFEzr91mmsA4HI14msyDgDDOu36csIzG5BYu2bFmEz5siQ8WntHgDtUjzJHP+VSSOTbAXhklEZHLA==
|
|
||||||
|
|
||||||
"@tootallnate/once@2":
|
"@tootallnate/once@2":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
||||||
@@ -4819,9 +4814,9 @@ cross-fetch@3.1.5:
|
|||||||
node-fetch "2.6.7"
|
node-fetch "2.6.7"
|
||||||
|
|
||||||
cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||||
version "7.0.3"
|
version "7.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||||
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
|
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key "^3.1.0"
|
path-key "^3.1.0"
|
||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
@@ -5428,10 +5423,10 @@ domhandler@^4.2.0, domhandler@^4.3.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype "^2.2.0"
|
domelementtype "^2.2.0"
|
||||||
|
|
||||||
dompurify@^3.0.5:
|
"dompurify@^3.0.5 <3.1.7":
|
||||||
version "3.1.4"
|
version "3.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.4.tgz#42121304b2b3a6bae22f80131ff8a8f3f3c56be2"
|
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2"
|
||||||
integrity sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww==
|
integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==
|
||||||
|
|
||||||
domutils@^2.8.0:
|
domutils@^2.8.0:
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
@@ -7818,10 +7813,10 @@ merge2@^1.3.0, merge2@^1.4.1:
|
|||||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||||
|
|
||||||
mermaid@10.9.0:
|
mermaid@10.9.3:
|
||||||
version "10.9.0"
|
version "10.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.0.tgz#4d1272fbe434bd8f3c2c150554dc8a23a9bf9361"
|
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7"
|
||||||
integrity sha512-swZju0hFox/B/qoLKK0rOxxgh8Cf7rJSfAUc1u8fezVihYMvrJAS45GzAxTVf4Q+xn9uMgitBcmWk7nWGXOs/g==
|
integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@braintree/sanitize-url" "^6.0.1"
|
"@braintree/sanitize-url" "^6.0.1"
|
||||||
"@types/d3-scale" "^4.0.3"
|
"@types/d3-scale" "^4.0.3"
|
||||||
@@ -7832,7 +7827,7 @@ mermaid@10.9.0:
|
|||||||
d3-sankey "^0.12.3"
|
d3-sankey "^0.12.3"
|
||||||
dagre-d3-es "7.0.10"
|
dagre-d3-es "7.0.10"
|
||||||
dayjs "^1.11.7"
|
dayjs "^1.11.7"
|
||||||
dompurify "^3.0.5"
|
dompurify "^3.0.5 <3.1.7"
|
||||||
elkjs "^0.9.0"
|
elkjs "^0.9.0"
|
||||||
katex "^0.16.9"
|
katex "^0.16.9"
|
||||||
khroma "^2.0.0"
|
khroma "^2.0.0"
|
||||||
@@ -9638,7 +9633,7 @@ string-natural-compare@^3.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
||||||
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3:
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -9656,15 +9651,6 @@ string-width@^4.1.0, string-width@^4.2.0:
|
|||||||
is-fullwidth-code-point "^3.0.0"
|
is-fullwidth-code-point "^3.0.0"
|
||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
|
|
||||||
string-width@^4.2.3:
|
|
||||||
version "4.2.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
|
||||||
dependencies:
|
|
||||||
emoji-regex "^8.0.0"
|
|
||||||
is-fullwidth-code-point "^3.0.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
|
|
||||||
string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2:
|
string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||||
@@ -9736,14 +9722,7 @@ stringify-object@^3.3.0:
|
|||||||
is-obj "^1.0.1"
|
is-obj "^1.0.1"
|
||||||
is-regexp "^1.0.0"
|
is-regexp "^1.0.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
|
||||||
version "6.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
|
||||||
dependencies:
|
|
||||||
ansi-regex "^5.0.1"
|
|
||||||
|
|
||||||
strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1:
|
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@@ -10980,7 +10959,7 @@ workbox-window@7.1.0, workbox-window@^7.0.0:
|
|||||||
"@types/trusted-types" "^2.0.2"
|
"@types/trusted-types" "^2.0.2"
|
||||||
workbox-core "7.1.0"
|
workbox-core "7.1.0"
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
@@ -10998,15 +10977,6 @@ wrap-ansi@^6.2.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
|
|
||||||
wrap-ansi@^7.0.0:
|
|
||||||
version "7.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
|
||||||
dependencies:
|
|
||||||
ansi-styles "^4.0.0"
|
|
||||||
string-width "^4.1.0"
|
|
||||||
strip-ansi "^6.0.0"
|
|
||||||
|
|
||||||
wrap-ansi@^8.1.0:
|
wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
|||||||
Reference in New Issue
Block a user