Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d1653d97 | ||
|
|
4d26993c8f | ||
|
|
1e69609ce4 | ||
|
|
f5379d1563 | ||
|
|
c8f6e3faa8 | ||
|
|
36bf17cf59 | ||
|
|
75458c3192 | ||
|
|
4cd25253bf | ||
|
|
78e254fb30 | ||
|
|
79bd3b8cda | ||
|
|
55110bf1b8 | ||
|
|
941b2d7042 | ||
|
|
e9067de173 | ||
|
|
fdc462ec01 | ||
|
|
d1441afec9 | ||
|
|
3298aaf0c7 | ||
|
|
e9a224a0de | ||
|
|
76cf560914 | ||
|
|
6c1246ef77 | ||
|
|
b477c2ad6b | ||
|
|
4cb6f09559 | ||
|
|
8636ef1017 | ||
|
|
3a776f8795 | ||
|
|
9929a2be6f | ||
|
|
9cccac1458 | ||
|
|
7eaf47c9d4 | ||
|
|
ec4b3d913e | ||
|
|
5390617c01 | ||
|
|
0d1058a596 | ||
|
|
c5869979c8 | ||
|
|
6a6b9c90a7 | ||
|
|
5c17751662 | ||
|
|
898789b979 | ||
|
|
7922ce129e | ||
|
|
59ec1c6cee | ||
|
|
933c6a2237 | ||
|
|
cd61f31116 | ||
|
|
b3052f0178 | ||
|
|
a271e42af1 | ||
|
|
836120c14b | ||
|
|
da4fa91ffc | ||
|
|
553b493f37 | ||
|
|
59a1d192d2 | ||
|
|
8b7302e89e | ||
|
|
f9b7cfd8aa | ||
|
|
2b4462c941 | ||
|
|
43b13d8e3a | ||
|
|
720f468f39 | ||
|
|
33300d19f6 | ||
|
|
5aed159991 | ||
|
|
de1d221d1c | ||
|
|
9a68dbffe2 | ||
|
|
32d82219b1 | ||
|
|
ba2c86fe1b | ||
|
|
f1ae37c84b | ||
|
|
ec350ba8b2 | ||
|
|
46a61ad4df | ||
|
|
f4b1a30bef | ||
|
|
32aa79164b | ||
|
|
b5fd904808 | ||
|
|
8f8dd1105f | ||
|
|
b914ad41fc | ||
|
|
551c38f60b | ||
|
|
38e8ae46c9 | ||
|
|
ad0c4c4c78 | ||
|
|
27cf5ed17e | ||
|
|
fd946adbae | ||
|
|
c37977af4b | ||
|
|
a0d413ab4e | ||
|
|
b67a2b4f65 | ||
|
|
5a8dbe8030 | ||
|
|
731093f631 | ||
|
|
fe56975f19 | ||
|
|
2d800feeeb | ||
|
|
93cccd596a | ||
|
|
45b592227d | ||
|
|
b818df1098 | ||
|
|
4359e2935d | ||
|
|
3d9d398378 | ||
|
|
0a5da0269f | ||
|
|
08ce7c7fc3 | ||
|
|
fe7fbff7f6 | ||
|
|
501397cb61 | ||
|
|
865d29388c | ||
|
|
54c7ec416a | ||
|
|
aca284057d | ||
|
|
2820cd112e | ||
|
|
426b5d9537 | ||
|
|
e7d34677c6 | ||
|
|
3d5356cb8e | ||
|
|
46f5ce5ce0 | ||
|
|
b00bd3d6c0 | ||
|
|
91fc22182c | ||
|
|
966ca2ffa6 |
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
// These tasks will run in order when initializing your CodeSandbox project.
|
||||||
|
"setupTasks": [
|
||||||
|
{
|
||||||
|
"name": "Install Dependencies",
|
||||||
|
"command": "yarn install"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// These tasks can be run from CodeSandbox. Running one will open a log in the app.
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"name": "Build",
|
||||||
|
"command": "yarn build",
|
||||||
|
"runAtStart": false
|
||||||
|
},
|
||||||
|
"fix": {
|
||||||
|
"name": "Fix",
|
||||||
|
"command": "yarn fix",
|
||||||
|
"runAtStart": false
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"name": "Prettify",
|
||||||
|
"command": "yarn prettier",
|
||||||
|
"runAtStart": false
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"name": "Start Excalidraw",
|
||||||
|
"command": "yarn start",
|
||||||
|
"runAtStart": true
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"name": "Run Tests",
|
||||||
|
"command": "yarn test",
|
||||||
|
"runAtStart": false
|
||||||
|
},
|
||||||
|
"install-deps": {
|
||||||
|
"name": "Install Dependencies",
|
||||||
|
"command": "yarn install",
|
||||||
|
"restartOn": { "files": ["yarn.lock"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,3 @@ src/packages/excalidraw/types
|
|||||||
src/packages/excalidraw/example/public/bundle.js
|
src/packages/excalidraw/example/public/bundle.js
|
||||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/exc
|
|||||||
|
|
||||||
### Code Sandbox
|
### Code Sandbox
|
||||||
|
|
||||||
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||||
- You may need to sign in with GitHub and reload the page
|
- You may need to sign in with GitHub and reload the page
|
||||||
- You can start coding instantly, and even send PRs from there!
|
- You can start coding instantly, and even send PRs from there!
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -41,7 +41,8 @@
|
|||||||
"nanoid": "3.3.3",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "1.0.16",
|
"perfect-freehand": "1.2.0",
|
||||||
|
"pica": "7.1.1",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
@@ -73,9 +74,6 @@
|
|||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0"
|
"rewire": "6.0.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
|
||||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,25 @@
|
|||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!------------------------------------------------------------------------->
|
||||||
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
//
|
||||||
|
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||||
|
if (theme === "dark") {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
html.dark {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!------------------------------------------------------------------------->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||||
//
|
//
|
||||||
@@ -98,7 +117,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
|
||||||
<script>
|
<script>
|
||||||
{
|
{
|
||||||
const _WebSocket = window.WebSocket;
|
const _WebSocket = window.WebSocket;
|
||||||
@@ -155,7 +174,7 @@
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
white-space: nowrap; /* added line */
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const crowdinMap = {
|
|||||||
"fa-IR": "en-fa",
|
"fa-IR": "en-fa",
|
||||||
"fi-FI": "en-fi",
|
"fi-FI": "en-fi",
|
||||||
"fr-FR": "en-fr",
|
"fr-FR": "en-fr",
|
||||||
|
"gl-ES": "en-gl",
|
||||||
"he-IL": "en-he",
|
"he-IL": "en-he",
|
||||||
"hi-IN": "en-hi",
|
"hi-IN": "en-hi",
|
||||||
"hu-HU": "en-hu",
|
"hu-HU": "en-hu",
|
||||||
@@ -23,6 +24,7 @@ const crowdinMap = {
|
|||||||
"ja-JP": "en-ja",
|
"ja-JP": "en-ja",
|
||||||
"kab-KAB": "en-kab",
|
"kab-KAB": "en-kab",
|
||||||
"ko-KR": "en-ko",
|
"ko-KR": "en-ko",
|
||||||
|
"ku-TR": "en-ku",
|
||||||
"my-MM": "en-my",
|
"my-MM": "en-my",
|
||||||
"nb-NO": "en-nb",
|
"nb-NO": "en-nb",
|
||||||
"nl-NL": "en-nl",
|
"nl-NL": "en-nl",
|
||||||
@@ -65,6 +67,7 @@ const flags = {
|
|||||||
"fa-IR": "🇮🇷",
|
"fa-IR": "🇮🇷",
|
||||||
"fi-FI": "🇫🇮",
|
"fi-FI": "🇫🇮",
|
||||||
"fr-FR": "🇫🇷",
|
"fr-FR": "🇫🇷",
|
||||||
|
"gl-ES": "🇪🇸",
|
||||||
"he-IL": "🇮🇱",
|
"he-IL": "🇮🇱",
|
||||||
"hi-IN": "🇮🇳",
|
"hi-IN": "🇮🇳",
|
||||||
"hu-HU": "🇭🇺",
|
"hu-HU": "🇭🇺",
|
||||||
@@ -74,6 +77,7 @@ const flags = {
|
|||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
"kk-KZ": "🇰🇿",
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
|
"ku-TR": "🏳",
|
||||||
"lt-LT": "🇱🇹",
|
"lt-LT": "🇱🇹",
|
||||||
"lv-LV": "🇱🇻",
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
// for development purposes we want to have the service-worker.js file
|
// for development purposes we want to have the service-worker.js file
|
||||||
// accessible from the public folder. On build though, we need to compile it
|
// accessible from the public folder. On build though, we need to compile it
|
||||||
// and CRA expects that file to be in src/ folder.
|
// and CRA expects that file to be in src/ folder.
|
||||||
const moveServiceWorkerScript = () => {
|
const moveServiceWorkerScript = () => {
|
||||||
const oldPath = "./public/service-worker.js";
|
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||||
const newPath = "./src/service-worker.js";
|
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||||
|
|
||||||
fs.rename(oldPath, newPath, (error) => {
|
fs.rename(oldPath, newPath, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ColorPicker } from "../components/ColorPicker";
|
|||||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { THEME, ZOOM_STEP } from "../constants";
|
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -206,7 +206,7 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
const zoomAdjustedToSteps =
|
const zoomAdjustedToSteps =
|
||||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||||
const clampedZoomValueToFitElements = Math.min(
|
const clampedZoomValueToFitElements = Math.min(
|
||||||
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const actionCut = register({
|
|||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
contextItemLabel: "labels.cut",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export const actionLoadScene = register({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={load}
|
icon={load}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
@@ -181,6 +181,11 @@ export const actionFinalize = register({
|
|||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
// To select the linear element when user has finished mutipoint editing
|
||||||
|
selectedLinearElement:
|
||||||
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
|
? new LinearElementEditor(multiPointElement, scene)
|
||||||
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
|||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getTransformHandles } from "../element/transformHandles";
|
import { getTransformHandles } from "../element/transformHandles";
|
||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
|
||||||
import { updateBoundElements } from "../element/binding";
|
import { updateBoundElements } from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
|
import {
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
getElementPointsCoords,
|
||||||
|
} from "../element/bounds";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -118,13 +122,6 @@ const flipElement = (
|
|||||||
const height = element.height;
|
const height = element.height;
|
||||||
const originalAngle = normalizeAngle(element.angle);
|
const originalAngle = normalizeAngle(element.angle);
|
||||||
|
|
||||||
let finalOffsetX = 0;
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
finalOffsetX =
|
|
||||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
|
||||||
element.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate back to zero, if necessary
|
// Rotate back to zero, if necessary
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
angle: normalizeAngle(0),
|
angle: normalizeAngle(0),
|
||||||
@@ -132,7 +129,6 @@ const flipElement = (
|
|||||||
// Flip unrotated by pulling TransformHandle to opposite side
|
// Flip unrotated by pulling TransformHandle to opposite side
|
||||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||||
let usingNWHandle = true;
|
let usingNWHandle = true;
|
||||||
let newNCoordsX = 0;
|
|
||||||
let nHandle = transformHandles.nw;
|
let nHandle = transformHandles.nw;
|
||||||
if (!nHandle) {
|
if (!nHandle) {
|
||||||
// Use ne handle instead
|
// Use ne handle instead
|
||||||
@@ -146,30 +142,51 @@ const flipElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalOffsetX = 0;
|
||||||
|
if (isLinearElement(element) && element.points.length < 3) {
|
||||||
|
finalOffsetX =
|
||||||
|
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||||
|
element.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialPointsCoords;
|
||||||
if (isLinearElement(element)) {
|
if (isLinearElement(element)) {
|
||||||
|
initialPointsCoords = getElementPointsCoords(
|
||||||
|
element,
|
||||||
|
element.points,
|
||||||
|
element.strokeSharpness,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
|
if (isLinearElement(element) && element.points.length < 3) {
|
||||||
for (let index = 1; index < element.points.length; index++) {
|
for (let index = 1; index < element.points.length; index++) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(element, [
|
||||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
{
|
||||||
|
index,
|
||||||
|
point: [-element.points[index][0], element.points[index][1]],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
} else {
|
} else {
|
||||||
// calculate new x-coord for transformation
|
const elWidth = initialPointsCoords
|
||||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
? initialPointsCoords[2] - initialPointsCoords[0]
|
||||||
|
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
|
||||||
|
|
||||||
|
const startPoint = initialPointsCoords
|
||||||
|
? [initialPointsCoords[0], initialPointsCoords[1]]
|
||||||
|
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
|
||||||
|
|
||||||
resizeSingleElement(
|
resizeSingleElement(
|
||||||
new Map().set(element.id, element),
|
new Map().set(element.id, element),
|
||||||
true,
|
false,
|
||||||
element,
|
element,
|
||||||
usingNWHandle ? "nw" : "ne",
|
usingNWHandle ? "nw" : "ne",
|
||||||
false,
|
true,
|
||||||
newNCoordsX,
|
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
|
||||||
nHandle[1],
|
startPoint[1],
|
||||||
);
|
);
|
||||||
// fix the size to account for handle sizes
|
|
||||||
mutateElement(element, {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate by (360 degrees - original angle)
|
// Rotate by (360 degrees - original angle)
|
||||||
@@ -186,9 +203,34 @@ const flipElement = (
|
|||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
x: originalX + finalOffsetX,
|
x: originalX + finalOffsetX,
|
||||||
y: originalY,
|
y: originalY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateBoundElements(element);
|
updateBoundElements(element);
|
||||||
|
|
||||||
|
if (initialPointsCoords && isLinearElement(element)) {
|
||||||
|
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
|
||||||
|
// There's still room for improvement since when the line roughness is > 1
|
||||||
|
// we still have a small offset of the origin when fliipping the element.
|
||||||
|
const finalPointsCoords = getElementPointsCoords(
|
||||||
|
element,
|
||||||
|
element.points,
|
||||||
|
element.strokeSharpness,
|
||||||
|
);
|
||||||
|
|
||||||
|
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
|
||||||
|
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
|
||||||
|
|
||||||
|
const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
|
||||||
|
|
||||||
|
mutateElement(element, {
|
||||||
|
x: element.x + coordsDiff * 0.5,
|
||||||
|
y: element.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -132,7 +132,7 @@ export const actionGroup = register({
|
|||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
enableActionGroup(elements, appState),
|
enableActionGroup(elements, appState),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
@@ -189,7 +189,9 @@ export const actionUngroup = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
event.shiftKey &&
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.key === KEYS.G.toUpperCase(),
|
||||||
contextItemLabel: "labels.ungroup",
|
contextItemLabel: "labels.ungroup",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
getSelectedGroupIds(appState).length > 0,
|
getSelectedGroupIds(appState).length > 0,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionToggleLinearEditor = register({
|
||||||
|
name: "toggleLinearEditor",
|
||||||
|
trackEvent: {
|
||||||
|
category: "element",
|
||||||
|
},
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
perform(elements, appState, _, app) {
|
||||||
|
const selectedElement = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
true,
|
||||||
|
)[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const editingLinearElement =
|
||||||
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
|
? null
|
||||||
|
: new LinearElementEditor(selectedElement, app.scene);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: (elements, appState) => {
|
||||||
|
const selectedElement = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
true,
|
||||||
|
)[0] as ExcalidrawLinearElement;
|
||||||
|
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
|
? "labels.lineEditor.exit"
|
||||||
|
: "labels.lineEditor.edit";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { t } from "../i18n";
|
|||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
@@ -67,7 +67,7 @@ export const actionFullScreen = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
|
|||||||
@@ -3,32 +3,42 @@ import { register } from "./register";
|
|||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const selectedElementIds = elements.reduce(
|
||||||
|
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId) &&
|
||||||
|
!element.locked
|
||||||
|
) {
|
||||||
|
map[element.id] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: selectGroupsForSelectedElements(
|
||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
|
selectedLinearElement:
|
||||||
|
// single linear element selected
|
||||||
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
|
isLinearElement(elements[0])
|
||||||
|
? new LinearElementEditor(elements[0], app.scene)
|
||||||
|
: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce(
|
selectedElementIds,
|
||||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
|
||||||
if (
|
|
||||||
!element.isDeleted &&
|
|
||||||
!(isTextElement(element) && element.containerId) &&
|
|
||||||
!element.locked
|
|
||||||
) {
|
|
||||||
map[element.id] = true;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ export const actionToggleLock = register({
|
|||||||
|
|
||||||
const operation = getOperation(selectedElements);
|
const operation = getOperation(selectedElements);
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
const lock = operation === "lock";
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (!selectedElementsMap.has(element.id)) {
|
if (!selectedElementsMap.has(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, { locked: operation === "lock" });
|
return newElementWith(element, { locked: lock });
|
||||||
}),
|
}),
|
||||||
appState,
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||||
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,3 +85,4 @@ export { actionToggleStats } from "./actionToggleStats";
|
|||||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||||
export { actionLink } from "../element/Hyperlink";
|
export { actionLink } from "../element/Hyperlink";
|
||||||
export { actionToggleLock } from "./actionToggleLock";
|
export { actionToggleLock } from "./actionToggleLock";
|
||||||
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ export class ActionManager {
|
|||||||
*/
|
*/
|
||||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.actions[name] &&
|
this.actions[name] &&
|
||||||
"PanelComponent" in this.actions[name] &&
|
"PanelComponent" in this.actions[name] &&
|
||||||
@@ -147,6 +146,7 @@ export class ActionManager {
|
|||||||
) {
|
) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
|
PanelComponent.displayName = "PanelComponent";
|
||||||
const elements = this.getElementsIncludingDeleted();
|
const elements = this.getElementsIncludingDeleted();
|
||||||
const appState = this.getAppState();
|
const appState = this.getAppState();
|
||||||
const updateData = (formState?: any) => {
|
const updateData = (formState?: any) => {
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export type ActionName =
|
|||||||
| "hyperlink"
|
| "hyperlink"
|
||||||
| "eraser"
|
| "eraser"
|
||||||
| "bindText"
|
| "bindText"
|
||||||
| "toggleLock";
|
| "toggleLock"
|
||||||
|
| "toggleLinearEditor";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isSidebarDocked: false,
|
||||||
isLibraryMenuDocked: false,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
@@ -67,6 +66,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
openPopup: null,
|
openPopup: null,
|
||||||
|
openSidebar: null,
|
||||||
pasteDialog: { shown: false, data: null },
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
@@ -90,6 +90,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,8 +148,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
gridSize: { browser: true, export: true, server: true },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
height: { browser: false, export: false, server: false },
|
height: { browser: false, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false, server: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
isLibraryOpen: { browser: true, export: false, server: false },
|
isSidebarDocked: { browser: true, export: false, server: false },
|
||||||
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
|
||||||
isLoading: { browser: false, export: false, server: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
isResizing: { browser: false, export: false, server: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
isRotating: { browser: false, export: false, server: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
@@ -159,6 +159,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
offsetTop: { browser: false, export: false, server: false },
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
openMenu: { browser: true, export: false, server: false },
|
openMenu: { browser: true, export: false, server: false },
|
||||||
openPopup: { browser: false, export: false, server: false },
|
openPopup: { browser: false, export: false, server: false },
|
||||||
|
openSidebar: { browser: true, export: false, server: false },
|
||||||
pasteDialog: { browser: false, export: false, server: false },
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
resizingElement: { browser: false, export: false, server: false },
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
@@ -181,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
pendingImageElementId: { browser: false, export: false, server: false },
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
|
|||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { actionToggleZenMode } from "../actions";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
activeTool,
|
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
activeTool: AppState["activeTool"]["type"];
|
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
|
|||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
hasBackground(activeTool) ||
|
hasBackground(appState.activeTool.type) ||
|
||||||
targetElements.some(
|
targetElements.some(
|
||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
const showChangeBackgroundIcons =
|
||||||
hasBackground(activeTool) ||
|
hasBackground(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
const showLinkIcon =
|
const showLinkIcon =
|
||||||
@@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{((hasStrokeColor(activeTool) &&
|
{((hasStrokeColor(appState.activeTool.type) &&
|
||||||
activeTool !== "image" &&
|
appState.activeTool.type !== "image" &&
|
||||||
commonSelectedType !== "image") ||
|
commonSelectedType !== "image") ||
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
renderAction("changeStrokeColor")}
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStrokeWidth(activeTool) ||
|
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||||
renderAction("changeStrokeWidth")}
|
renderAction("changeStrokeWidth")}
|
||||||
|
|
||||||
{(activeTool === "freedraw" ||
|
{(appState.activeTool.type === "freedraw" ||
|
||||||
targetElements.some((element) => element.type === "freedraw")) &&
|
targetElements.some((element) => element.type === "freedraw")) &&
|
||||||
renderAction("changeStrokeShape")}
|
renderAction("changeStrokeShape")}
|
||||||
|
|
||||||
{(hasStrokeStyle(activeTool) ||
|
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeStyle")}
|
{renderAction("changeStrokeStyle")}
|
||||||
@@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canChangeSharpness(activeTool) ||
|
{(canChangeSharpness(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||||
<>{renderAction("changeSharpness")}</>
|
<>{renderAction("changeSharpness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText(activeTool) ||
|
{(hasText(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
{renderAction("changeFontSize")}
|
||||||
@@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
|
|||||||
(element) =>
|
(element) =>
|
||||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||||
) && renderAction("changeVerticalAlign")}
|
) && renderAction("changeVerticalAlign")}
|
||||||
{(canHaveArrowheads(activeTool) ||
|
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
@@ -271,3 +271,45 @@ export const ZoomActions = ({
|
|||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const UndoRedoActions = ({
|
||||||
|
renderAction,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
renderAction: ActionManager["renderAction"];
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={`undo-redo-buttons ${className}`}>
|
||||||
|
{renderAction("undo", { size: "small" })}
|
||||||
|
{renderAction("redo", { size: "small" })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ExitZenModeAction = ({
|
||||||
|
actionManager,
|
||||||
|
showExitZenModeBtn,
|
||||||
|
}: {
|
||||||
|
actionManager: ActionManager;
|
||||||
|
showExitZenModeBtn: boolean;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
className={clsx("disable-zen-mode", {
|
||||||
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
|
})}
|
||||||
|
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||||
|
>
|
||||||
|
{t("buttons.exitZenMode")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FinalizeAction = ({
|
||||||
|
renderAction,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
renderAction: ActionManager["renderAction"];
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={`finalize-button ${className}`}>
|
||||||
|
{renderAction("finalize", { size: "small" })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import React from "react";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
export const BackgroundPickerAndDarkModeToggle = ({
|
export const BackgroundPickerAndDarkModeToggle = ({
|
||||||
appState,
|
|
||||||
setAppState,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
showThemeBtn,
|
|
||||||
}: {
|
}: {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
showThemeBtn: boolean;
|
|
||||||
}) => (
|
}) => (
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
{actionManager.renderAction("toggleTheme")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ const ColorInput = React.forwardRef(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColorInput.displayName = "ColorInput";
|
||||||
|
|
||||||
export const ColorPicker = ({
|
export const ColorPicker = ({
|
||||||
type,
|
type,
|
||||||
color,
|
color,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
title={t("buttons.close")}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useDevice().isMobile ? back : close}
|
{useDevice().isMobile ? back : close}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
|
import {
|
||||||
|
ExitZenModeAction,
|
||||||
|
FinalizeAction,
|
||||||
|
UndoRedoActions,
|
||||||
|
ZoomActions,
|
||||||
|
} from "./Actions";
|
||||||
|
import { useDevice } from "./App";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
|
||||||
|
const Footer = ({
|
||||||
|
appState,
|
||||||
|
actionManager,
|
||||||
|
renderCustomFooter,
|
||||||
|
showExitZenModeBtn,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
actionManager: ActionManager;
|
||||||
|
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||||
|
showExitZenModeBtn: boolean;
|
||||||
|
}) => {
|
||||||
|
const device = useDevice();
|
||||||
|
const showFinalize =
|
||||||
|
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
role="contentinfo"
|
||||||
|
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx("layer-ui__wrapper__footer-left zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack.Col gap={2}>
|
||||||
|
<Section heading="canvasActions">
|
||||||
|
<Island padding={1}>
|
||||||
|
<ZoomActions
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
zoom={appState.zoom}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
{!appState.viewModeEnabled && (
|
||||||
|
<>
|
||||||
|
<UndoRedoActions
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
className={clsx("zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx("eraser-buttons zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{actionManager.renderAction("eraser", { size: "small" })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showFinalize && (
|
||||||
|
<FinalizeAction
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
className={clsx("zen-mode-transition", {
|
||||||
|
"layer-ui__wrapper__footer-left--transition-left":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||||
|
{
|
||||||
|
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||||
|
appState.zenModeEnabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCustomFooter?.(false, appState)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
|
||||||
|
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
|
</div>
|
||||||
|
<ExitZenModeAction
|
||||||
|
actionManager={actionManager}
|
||||||
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState, Device } from "../types";
|
||||||
import {
|
import {
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@@ -17,13 +17,19 @@ interface HintViewerProps {
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
device: Device;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
const getHints = ({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
isMobile,
|
||||||
|
device,
|
||||||
|
}: HintViewerProps) => {
|
||||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (appState.isLibraryOpen) {
|
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +117,13 @@ export const HintViewer = ({
|
|||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
device,
|
||||||
}: HintViewerProps) => {
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
device,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
if (!hint) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import React, { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
|
theme?: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InitializeApp = (props: Props) => {
|
export const InitializeApp = (props: Props) => {
|
||||||
@@ -21,5 +23,5 @@ export const InitializeApp = (props: Props) => {
|
|||||||
updateLang();
|
updateLang();
|
||||||
}, [props.langCode]);
|
}, [props.langCode]);
|
||||||
|
|
||||||
return loading ? <LoadingMessage /> : props.children;
|
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +1,6 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sat);
|
|
||||||
bottom: var(--sab);
|
|
||||||
right: var(--sar);
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
box-shadow: var(--shadow-island);
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
margin: var(--space-factor);
|
|
||||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon {
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon__icon__close {
|
|
||||||
.Modal__close {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Island {
|
|
||||||
--padding: 0;
|
|
||||||
background-color: var(--island-bg-color);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
|
||||||
position: relative;
|
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__wrapper.animate {
|
.layer-ui__wrapper.animate {
|
||||||
transition: width 0.1s ease-in-out;
|
transition: width 0.1s ease-in-out;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback } from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
@@ -26,7 +26,7 @@ import { Section } from "./Section";
|
|||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library from "../data/library";
|
||||||
import { JSONExportDialog } from "./JSONExportDialog";
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
@@ -39,7 +39,10 @@ import { trackEvent } from "../analytics";
|
|||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||||
import { actionToggleZenMode } from "../actions";
|
import Footer from "./Footer";
|
||||||
|
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -53,12 +56,12 @@ interface LayerUIProps {
|
|||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
showExitZenModeBtn: boolean;
|
showExitZenModeBtn: boolean;
|
||||||
showThemeBtn: boolean;
|
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
|
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
UIOptions: AppProps["UIOptions"];
|
UIOptions: AppProps["UIOptions"];
|
||||||
focusContainer: () => void;
|
focusContainer: () => void;
|
||||||
@@ -71,18 +74,18 @@ const LayerUI = ({
|
|||||||
appState,
|
appState,
|
||||||
files,
|
files,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
|
||||||
elements,
|
elements,
|
||||||
|
canvas,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onPenModeToggle,
|
onPenModeToggle,
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
showExitZenModeBtn,
|
showExitZenModeBtn,
|
||||||
showThemeBtn,
|
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
renderCustomStats,
|
renderCustomStats,
|
||||||
|
renderCustomSidebar,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
UIOptions,
|
UIOptions,
|
||||||
focusContainer,
|
focusContainer,
|
||||||
@@ -209,12 +212,7 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<BackgroundPickerAndDarkModeToggle
|
<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />
|
||||||
actionManager={actionManager}
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
showThemeBtn={showThemeBtn}
|
|
||||||
/>
|
|
||||||
{appState.fileHandle && (
|
{appState.fileHandle && (
|
||||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||||
)}
|
)}
|
||||||
@@ -244,48 +242,11 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
activeTool={appState.activeTool.type}
|
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(() => {
|
|
||||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
|
||||||
|
|
||||||
// Prevent closing if any dialog is open
|
|
||||||
if (isDialogOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
}, [setAppState]);
|
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
|
||||||
setAppState({
|
|
||||||
selectedElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
|
||||||
});
|
|
||||||
}, [setAppState]);
|
|
||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
|
||||||
<LibraryMenu
|
|
||||||
pendingElements={getSelectedElements(elements, appState, true)}
|
|
||||||
onClose={closeLibrary}
|
|
||||||
onInsertLibraryItems={(libraryItems) => {
|
|
||||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
|
||||||
}}
|
|
||||||
onAddToLibrary={deselectItems}
|
|
||||||
setAppState={setAppState}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
focusContainer={focusContainer}
|
|
||||||
library={library}
|
|
||||||
theme={appState.theme}
|
|
||||||
files={files}
|
|
||||||
id={id}
|
|
||||||
appState={appState}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
appState,
|
appState,
|
||||||
@@ -339,6 +300,7 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
isMobile={device.isMobile}
|
isMobile={device.isMobile}
|
||||||
|
device={device}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
@@ -383,100 +345,24 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBottomAppMenu = () => {
|
const renderSidebars = () => {
|
||||||
return (
|
return appState.openSidebar === "customSidebar" ? (
|
||||||
<footer
|
renderCustomSidebar?.() || null
|
||||||
role="contentinfo"
|
) : appState.openSidebar === "library" ? (
|
||||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
<LibraryMenu
|
||||||
>
|
appState={appState}
|
||||||
<div
|
onInsertElements={onInsertElements}
|
||||||
className={clsx(
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
focusContainer={focusContainer}
|
||||||
{
|
library={library}
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
id={id}
|
||||||
appState.zenModeEnabled,
|
/>
|
||||||
},
|
) : null;
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Stack.Col gap={2}>
|
|
||||||
<Section heading="canvasActions">
|
|
||||||
<Island padding={1}>
|
|
||||||
<ZoomActions
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
zoom={appState.zoom}
|
|
||||||
/>
|
|
||||||
</Island>
|
|
||||||
{!appState.viewModeEnabled && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
|
||||||
appState.zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("undo", { size: "small" })}
|
|
||||||
{actionManager.renderAction("redo", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={clsx("eraser-buttons zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
|
||||||
appState.zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("eraser", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!appState.viewModeEnabled &&
|
|
||||||
appState.multiElement &&
|
|
||||||
device.isTouchScreen && (
|
|
||||||
<div
|
|
||||||
className={clsx("finalize-button zen-mode-transition", {
|
|
||||||
"layer-ui__wrapper__footer-left--transition-left":
|
|
||||||
appState.zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("finalize", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
|
||||||
{
|
|
||||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
|
||||||
appState.zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderCustomFooter?.(false, appState)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
|
||||||
{
|
|
||||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={clsx("disable-zen-mode", {
|
|
||||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
|
||||||
})}
|
|
||||||
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
|
||||||
>
|
|
||||||
{t("buttons.exitZenMode")}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogs = (
|
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
@@ -504,86 +390,80 @@ const LayerUI = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
{device.isMobile && (
|
||||||
);
|
<MobileMenu
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
actionManager={actionManager}
|
||||||
|
renderJSONExportDialog={renderJSONExportDialog}
|
||||||
|
renderImageExportDialog={renderImageExportDialog}
|
||||||
|
setAppState={setAppState}
|
||||||
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
|
onLockToggle={() => onLockToggle()}
|
||||||
|
onPenModeToggle={onPenModeToggle}
|
||||||
|
canvas={canvas}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
renderCustomFooter={renderCustomFooter}
|
||||||
|
onImageAction={onImageAction}
|
||||||
|
renderTopRightUI={renderTopRightUI}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
renderSidebars={renderSidebars}
|
||||||
|
device={device}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
const renderStats = () => {
|
{!device.isMobile && (
|
||||||
if (!appState.showStats) {
|
<>
|
||||||
return null;
|
<div
|
||||||
}
|
className={clsx("layer-ui__wrapper", {
|
||||||
return (
|
"disable-pointerEvents":
|
||||||
<Stats
|
appState.draggingElement ||
|
||||||
appState={appState}
|
appState.resizingElement ||
|
||||||
setAppState={setAppState}
|
(appState.editingElement &&
|
||||||
elements={elements}
|
!isTextElement(appState.editingElement)),
|
||||||
onClose={() => {
|
})}
|
||||||
actionManager.executeAction(actionToggleStats);
|
style={
|
||||||
}}
|
((appState.openSidebar === "library" &&
|
||||||
renderCustomStats={renderCustomStats}
|
appState.isSidebarDocked) ||
|
||||||
/>
|
hostSidebarCounters.docked) &&
|
||||||
);
|
device.canDeviceFitSidebar
|
||||||
};
|
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||||
|
: {}
|
||||||
return device.isMobile ? (
|
}
|
||||||
<>
|
|
||||||
{dialogs}
|
|
||||||
<MobileMenu
|
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
actionManager={actionManager}
|
|
||||||
libraryMenu={libraryMenu}
|
|
||||||
renderJSONExportDialog={renderJSONExportDialog}
|
|
||||||
renderImageExportDialog={renderImageExportDialog}
|
|
||||||
setAppState={setAppState}
|
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
|
||||||
onLockToggle={() => onLockToggle()}
|
|
||||||
onPenModeToggle={onPenModeToggle}
|
|
||||||
canvas={canvas}
|
|
||||||
isCollaborating={isCollaborating}
|
|
||||||
renderCustomFooter={renderCustomFooter}
|
|
||||||
showThemeBtn={showThemeBtn}
|
|
||||||
onImageAction={onImageAction}
|
|
||||||
renderTopRightUI={renderTopRightUI}
|
|
||||||
renderStats={renderStats}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={clsx("layer-ui__wrapper", {
|
|
||||||
"disable-pointerEvents":
|
|
||||||
appState.draggingElement ||
|
|
||||||
appState.resizingElement ||
|
|
||||||
(appState.editingElement &&
|
|
||||||
!isTextElement(appState.editingElement)),
|
|
||||||
})}
|
|
||||||
style={
|
|
||||||
appState.isLibraryOpen &&
|
|
||||||
appState.isLibraryMenuDocked &&
|
|
||||||
device.canDeviceFitSidebar
|
|
||||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{dialogs}
|
|
||||||
{renderFixedSideContainer()}
|
|
||||||
{renderBottomAppMenu()}
|
|
||||||
{renderStats()}
|
|
||||||
{appState.scrolledOutside && (
|
|
||||||
<button
|
|
||||||
className="scroll-back-to-content"
|
|
||||||
onClick={() => {
|
|
||||||
setAppState({
|
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("buttons.scrollBackToContent")}
|
{renderFixedSideContainer()}
|
||||||
</button>
|
<Footer
|
||||||
)}
|
appState={appState}
|
||||||
</div>
|
actionManager={actionManager}
|
||||||
{appState.isLibraryOpen && (
|
renderCustomFooter={renderCustomFooter}
|
||||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
showExitZenModeBtn={showExitZenModeBtn}
|
||||||
|
/>
|
||||||
|
{appState.showStats && (
|
||||||
|
<Stats
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
elements={elements}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{appState.scrolledOutside && (
|
||||||
|
<button
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState({
|
||||||
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderSidebars()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -602,8 +482,12 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
const nextAppState = getNecessaryObj(next.appState);
|
const nextAppState = getNecessaryObj(next.appState);
|
||||||
|
|
||||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||||
|
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||||
|
prev.renderCustomStats === next.renderCustomStats &&
|
||||||
|
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||||
prev.langCode === next.langCode &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
prev.files === next.files &&
|
prev.files === next.files &&
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{
|
|||||||
document
|
document
|
||||||
.querySelector(".layer-ui__wrapper")
|
.querySelector(".layer-ui__wrapper")
|
||||||
?.classList.remove("animate");
|
?.classList.remove("animate");
|
||||||
const nextState = event.target.checked;
|
const isOpen = event.target.checked;
|
||||||
setAppState({ isLibraryOpen: nextState });
|
setAppState({ openSidebar: isOpen ? "library" : null });
|
||||||
// track only openings
|
// track only openings
|
||||||
if (nextState) {
|
if (isOpen) {
|
||||||
trackEvent(
|
trackEvent(
|
||||||
"library",
|
"library",
|
||||||
"toggleLibrary (open)",
|
"toggleLibrary (open)",
|
||||||
@@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
checked={appState.isLibraryOpen}
|
checked={appState.openSidebar === "library"}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
aria-keyshortcuts="0"
|
aria-keyshortcuts="0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.layer-ui__library-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-ui__library {
|
.layer-ui__library {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
.layer-ui__library-header {
|
.layer-ui__library-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -23,16 +29,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__sidebar {
|
.layer-ui__sidebar {
|
||||||
.layer-ui__library {
|
|
||||||
padding: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.library-menu-items-container {
|
.library-menu-items-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
margin-right: auto;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
button .library-actions-counter {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--remove {
|
||||||
|
background-color: $oc-red-7;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-red-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--export {
|
||||||
|
background-color: $oc-lime-5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-lime-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-lime-8;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-lime-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--publish {
|
||||||
|
background-color: $oc-cyan-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-cyan-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-cyan-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
margin-left: -0.2em;
|
||||||
|
margin-right: 1.1em;
|
||||||
|
color: $oc-white;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-cyan-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--load {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-blue-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.layer-ui__library-message {
|
.layer-ui__library-message {
|
||||||
padding: 2em 4em;
|
padding: 2em 4em;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
|||||||
@@ -6,30 +6,31 @@ import {
|
|||||||
RefObject,
|
RefObject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import Library, { libraryItemsAtom } from "../data/library";
|
import Library, {
|
||||||
|
distributeLibraryItemsOnSquareGrid,
|
||||||
|
libraryItemsAtom,
|
||||||
|
} from "../data/library";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import {
|
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||||
LibraryItems,
|
|
||||||
LibraryItem,
|
|
||||||
AppState,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawProps,
|
|
||||||
} from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { Island } from "./Island";
|
|
||||||
import PublishLibrary from "./PublishLibrary";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
import "./LibraryMenu.scss";
|
import "./LibraryMenu.scss";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { EVENT } from "../constants";
|
import { EVENT, VERSIONS } from "../constants";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { useDevice } from "./App";
|
import {
|
||||||
|
useDevice,
|
||||||
|
useExcalidrawElements,
|
||||||
|
useExcalidrawSetAppState,
|
||||||
|
} from "./App";
|
||||||
|
import { Sidebar } from "./Sidebar/Sidebar";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
@@ -59,112 +60,45 @@ const useOnClickOutside = (
|
|||||||
}, [ref, cb]);
|
}, [ref, cb]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedItems = (
|
|
||||||
libraryItems: LibraryItems,
|
|
||||||
selectedItems: LibraryItem["id"][],
|
|
||||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
|
||||||
|
|
||||||
const LibraryMenuWrapper = forwardRef<
|
const LibraryMenuWrapper = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ children: React.ReactNode }
|
{ children: React.ReactNode }
|
||||||
>(({ children }, ref) => {
|
>(({ children }, ref) => {
|
||||||
return (
|
return (
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
<div ref={ref} className="layer-ui__library">
|
||||||
{children}
|
{children}
|
||||||
</Island>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LibraryMenu = ({
|
export const LibraryMenuContent = ({
|
||||||
onClose,
|
|
||||||
onInsertLibraryItems,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
theme,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
files,
|
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
focusContainer,
|
|
||||||
library,
|
library,
|
||||||
id,
|
id,
|
||||||
appState,
|
appState,
|
||||||
|
selectedItems,
|
||||||
|
onSelectItems,
|
||||||
}: {
|
}: {
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onClose: () => void;
|
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: () => void;
|
onAddToLibrary: () => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
focusContainer: () => void;
|
|
||||||
library: Library;
|
library: Library;
|
||||||
id: string;
|
id: string;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
const device = useDevice();
|
|
||||||
|
|
||||||
useOnClickOutside(
|
|
||||||
ref,
|
|
||||||
useCallback(
|
|
||||||
(event) => {
|
|
||||||
// If click on the library icon, do nothing.
|
|
||||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === KEYS.ESCAPE &&
|
|
||||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
|
||||||
) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
|
||||||
|
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
|
||||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
|
||||||
useState(false);
|
|
||||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
|
||||||
url: string;
|
|
||||||
authorName: string;
|
|
||||||
}>(null);
|
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(
|
|
||||||
async (libraryItems: LibraryItems) => {
|
|
||||||
const nextItems = libraryItems.filter(
|
|
||||||
(item) => !selectedItems.includes(item.id),
|
|
||||||
);
|
|
||||||
library.setLibrary(nextItems).catch(() => {
|
|
||||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
|
||||||
});
|
|
||||||
setSelectedItems([]);
|
|
||||||
},
|
|
||||||
[library, setAppState, selectedItems, setSelectedItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetLibrary = useCallback(() => {
|
|
||||||
library.resetLibrary();
|
|
||||||
focusContainer();
|
|
||||||
}, [library, focusContainer]);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
const addToLibrary = useCallback(
|
||||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
@@ -190,60 +124,12 @@ export const LibraryMenu = ({
|
|||||||
[onAddToLibrary, library, setAppState],
|
[onAddToLibrary, library, setAppState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPublishSuccess = useCallback(() => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
|
||||||
title={t("publishSuccessDialog.title")}
|
|
||||||
className="publish-library-success"
|
|
||||||
small={true}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{t("publishSuccessDialog.content", {
|
|
||||||
authorName: publishLibSuccess!.authorName,
|
|
||||||
})}{" "}
|
|
||||||
<a
|
|
||||||
href={publishLibSuccess?.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t("publishSuccessDialog.link")}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.close")}
|
|
||||||
aria-label={t("buttons.close")}
|
|
||||||
label={t("buttons.close")}
|
|
||||||
onClick={() => setPublishLibSuccess(null)}
|
|
||||||
data-testid="publish-library-success-close"
|
|
||||||
className="publish-library-success-close"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
|
||||||
|
|
||||||
const onPublishLibSuccess = useCallback(
|
|
||||||
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
|
||||||
setShowPublishLibraryDialog(false);
|
|
||||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
|
||||||
const nextLibItems = libraryItems.slice();
|
|
||||||
nextLibItems.forEach((libItem) => {
|
|
||||||
if (selectedItems.includes(libItem.id)) {
|
|
||||||
libItem.status = "published";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
library.setLibrary(nextLibItems);
|
|
||||||
},
|
|
||||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
libraryItemsData.status === "loading" &&
|
libraryItemsData.status === "loading" &&
|
||||||
!libraryItemsData.isInitialized
|
!libraryItemsData.isInitialized
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper>
|
||||||
<div className="layer-ui__library-message">
|
<div className="layer-ui__library-message">
|
||||||
<Spinner size="2em" />
|
<Spinner size="2em" />
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
@@ -253,51 +139,168 @@ export const LibraryMenu = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper ref={ref}>
|
<LibraryMenuWrapper>
|
||||||
{showPublishLibraryDialog && (
|
|
||||||
<PublishLibrary
|
|
||||||
onClose={() => setShowPublishLibraryDialog(false)}
|
|
||||||
libraryItems={getSelectedItems(
|
|
||||||
libraryItemsData.libraryItems,
|
|
||||||
selectedItems,
|
|
||||||
)}
|
|
||||||
appState={appState}
|
|
||||||
onSuccess={(data) =>
|
|
||||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onError={(error) => window.alert(error)}
|
|
||||||
updateItemsInStorage={() =>
|
|
||||||
library.setLibrary(libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onRemove={(id: string) =>
|
|
||||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{publishLibSuccess && renderPublishSuccess()}
|
|
||||||
<LibraryMenuItems
|
<LibraryMenuItems
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
isLoading={libraryItemsData.status === "loading"}
|
||||||
libraryItems={libraryItemsData.libraryItems}
|
libraryItems={libraryItemsData.libraryItems}
|
||||||
onRemoveFromLibrary={() =>
|
|
||||||
removeFromLibrary(libraryItemsData.libraryItems)
|
|
||||||
}
|
|
||||||
onAddToLibrary={(elements) =>
|
onAddToLibrary={(elements) =>
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||||
}
|
}
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
onInsertLibraryItems={onInsertLibraryItems}
|
||||||
pendingElements={pendingElements}
|
pendingElements={pendingElements}
|
||||||
setAppState={setAppState}
|
|
||||||
appState={appState}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
library={library}
|
|
||||||
theme={theme}
|
|
||||||
files={files}
|
|
||||||
id={id}
|
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
onSelectItems={onSelectItems}
|
||||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
|
||||||
resetLibrary={resetLibrary}
|
|
||||||
/>
|
/>
|
||||||
|
<a
|
||||||
|
className="library-menu-browse-button"
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${
|
||||||
|
appState.theme
|
||||||
|
}&version=${VERSIONS.excalidrawLibrary}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
</LibraryMenuWrapper>
|
</LibraryMenuWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LibraryMenu: React.FC<{
|
||||||
|
appState: AppState;
|
||||||
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
}> = ({
|
||||||
|
appState,
|
||||||
|
onInsertElements,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
}) => {
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const elements = useExcalidrawElements();
|
||||||
|
const device = useDevice();
|
||||||
|
|
||||||
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const closeLibrary = useCallback(() => {
|
||||||
|
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||||
|
|
||||||
|
// Prevent closing if any dialog is open
|
||||||
|
if (isDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppState({ openSidebar: null });
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
|
useOnClickOutside(
|
||||||
|
ref,
|
||||||
|
useCallback(
|
||||||
|
(event) => {
|
||||||
|
// If click on the library icon, do nothing so that LibraryButton
|
||||||
|
// can toggle library menu
|
||||||
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
|
||||||
|
closeLibrary();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === KEYS.ESCAPE &&
|
||||||
|
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
|
||||||
|
) {
|
||||||
|
closeLibrary();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
|
||||||
|
|
||||||
|
const deselectItems = useCallback(() => {
|
||||||
|
setAppState({
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
});
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(
|
||||||
|
async (libraryItems: LibraryItems) => {
|
||||||
|
const nextItems = libraryItems.filter(
|
||||||
|
(item) => !selectedItems.includes(item.id),
|
||||||
|
);
|
||||||
|
library.setLibrary(nextItems).catch(() => {
|
||||||
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
|
});
|
||||||
|
setSelectedItems([]);
|
||||||
|
},
|
||||||
|
[library, setAppState, selectedItems, setSelectedItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetLibrary = useCallback(() => {
|
||||||
|
library.resetLibrary();
|
||||||
|
focusContainer();
|
||||||
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
__isInternal
|
||||||
|
// necessary to remount when switching between internal
|
||||||
|
// and custom (host app) sidebar, so that the `props.onClose`
|
||||||
|
// is colled correctly
|
||||||
|
key="library"
|
||||||
|
className="layer-ui__library-sidebar"
|
||||||
|
onDock={(docked) => {
|
||||||
|
trackEvent(
|
||||||
|
"library",
|
||||||
|
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
||||||
|
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Sidebar.Header className="layer-ui__library-header">
|
||||||
|
<LibraryMenuHeader
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={setSelectedItems}
|
||||||
|
library={library}
|
||||||
|
onRemoveFromLibrary={() =>
|
||||||
|
removeFromLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
</Sidebar.Header>
|
||||||
|
<LibraryMenuContent
|
||||||
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
|
onInsertLibraryItems={(libraryItems) => {
|
||||||
|
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||||
|
}}
|
||||||
|
onAddToLibrary={deselectItems}
|
||||||
|
setAppState={setAppState}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
library={library}
|
||||||
|
id={id}
|
||||||
|
appState={appState}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectItems={setSelectedItems}
|
||||||
|
/>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { saveLibraryAsJSON } from "../data/json";
|
||||||
|
import Library, { libraryItemsAtom } from "../data/library";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||||
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
import { fileOpen } from "../data/filesystem";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import PublishLibrary from "./PublishLibrary";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
|
const getSelectedItems = (
|
||||||
|
libraryItems: LibraryItems,
|
||||||
|
selectedItems: LibraryItem["id"][],
|
||||||
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
export const LibraryMenuHeader: React.FC<{
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
library: Library;
|
||||||
|
onRemoveFromLibrary: () => void;
|
||||||
|
resetLibrary: () => void;
|
||||||
|
onSelectItems: (items: LibraryItem["id"][]) => void;
|
||||||
|
appState: AppState;
|
||||||
|
}> = ({
|
||||||
|
setAppState,
|
||||||
|
selectedItems,
|
||||||
|
library,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
resetLibrary,
|
||||||
|
onSelectItems,
|
||||||
|
appState,
|
||||||
|
}) => {
|
||||||
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
|
const renderRemoveLibAlert = useCallback(() => {
|
||||||
|
const content = selectedItems.length
|
||||||
|
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||||
|
: t("alerts.resetLibrary");
|
||||||
|
const title = selectedItems.length
|
||||||
|
? t("confirmDialog.removeItemsFromLib")
|
||||||
|
: t("confirmDialog.resetLibrary");
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedItems.length) {
|
||||||
|
onRemoveFromLibrary();
|
||||||
|
} else {
|
||||||
|
resetLibrary();
|
||||||
|
}
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<p>{content}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
|
||||||
|
const itemsSelected = !!selectedItems.length;
|
||||||
|
const items = itemsSelected
|
||||||
|
? libraryItemsData.libraryItems.filter((item) =>
|
||||||
|
selectedItems.includes(item.id),
|
||||||
|
)
|
||||||
|
: libraryItemsData.libraryItems;
|
||||||
|
const resetLabel = itemsSelected
|
||||||
|
? t("buttons.remove")
|
||||||
|
: t("buttons.resetLibrary");
|
||||||
|
|
||||||
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
|
useState(false);
|
||||||
|
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
}>(null);
|
||||||
|
const renderPublishSuccess = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||||
|
title={t("publishSuccessDialog.title")}
|
||||||
|
className="publish-library-success"
|
||||||
|
small={true}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("publishSuccessDialog.content", {
|
||||||
|
authorName: publishLibSuccess!.authorName,
|
||||||
|
})}{" "}
|
||||||
|
<a
|
||||||
|
href={publishLibSuccess?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishSuccessDialog.link")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.close")}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
label={t("buttons.close")}
|
||||||
|
onClick={() => setPublishLibSuccess(null)}
|
||||||
|
data-testid="publish-library-success-close"
|
||||||
|
className="publish-library-success-close"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
|
const onPublishLibSuccess = useCallback(
|
||||||
|
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||||
|
setShowPublishLibraryDialog(false);
|
||||||
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
|
const nextLibItems = libraryItems.slice();
|
||||||
|
nextLibItems.forEach((libItem) => {
|
||||||
|
if (selectedItems.includes(libItem.id)) {
|
||||||
|
libItem.status = "published";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
library.setLibrary(nextLibItems);
|
||||||
|
},
|
||||||
|
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLibraryImport = async () => {
|
||||||
|
try {
|
||||||
|
await library.updateLibrary({
|
||||||
|
libraryItems: fileOpen({
|
||||||
|
description: "Excalidraw library files",
|
||||||
|
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||||
|
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||||
|
/*
|
||||||
|
extensions: [".json", ".excalidrawlib"],
|
||||||
|
*/
|
||||||
|
}),
|
||||||
|
merge: true,
|
||||||
|
openLibraryMenu: true,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLibraryExport = async () => {
|
||||||
|
const libraryItems = itemsSelected
|
||||||
|
? items
|
||||||
|
: await library.getLatestLibrary();
|
||||||
|
saveLibraryAsJSON(libraryItems)
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-actions">
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
{showPublishLibraryDialog && (
|
||||||
|
<PublishLibrary
|
||||||
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
|
libraryItems={getSelectedItems(
|
||||||
|
libraryItemsData.libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
)}
|
||||||
|
appState={appState}
|
||||||
|
onSuccess={(data) =>
|
||||||
|
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
onError={(error) => window.alert(error)}
|
||||||
|
updateItemsInStorage={() =>
|
||||||
|
library.setLibrary(libraryItemsData.libraryItems)
|
||||||
|
}
|
||||||
|
onRemove={(id: string) =>
|
||||||
|
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
|
{!itemsSelected && (
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={onLibraryImport}
|
||||||
|
className="library-actions--load"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportToFileIcon}
|
||||||
|
onClick={onLibraryExport}
|
||||||
|
className="library-actions--export"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={resetLabel}
|
||||||
|
aria-label={resetLabel}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => setShowRemoveLibAlert(true)}
|
||||||
|
className="library-actions--remove"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{itemsSelected && (
|
||||||
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.publishLibrary")}
|
||||||
|
label={t("buttons.publishLibrary")}
|
||||||
|
icon={publishIcon}
|
||||||
|
className="library-actions--publish"
|
||||||
|
onClick={() => setShowPublishLibraryDialog(true)}
|
||||||
|
>
|
||||||
|
<label>{t("buttons.publishLibrary")}</label>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,96 +5,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0.5rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.library-actions {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
margin-right: auto;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
button .library-actions-counter {
|
|
||||||
position: absolute;
|
|
||||||
right: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
padding: 1px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--remove {
|
|
||||||
background-color: $oc-red-7;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-red-8;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-red-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-red-7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--export {
|
|
||||||
background-color: $oc-lime-5;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-lime-7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-lime-8;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-lime-5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--publish {
|
|
||||||
background-color: $oc-cyan-6;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-cyan-7;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-cyan-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
margin-left: -0.2em;
|
|
||||||
margin-right: 1.1em;
|
|
||||||
color: $oc-white;
|
|
||||||
font-size: 0.86em;
|
|
||||||
}
|
|
||||||
.library-actions-counter {
|
|
||||||
color: $oc-cyan-6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--load {
|
|
||||||
background-color: $oc-blue-6;
|
|
||||||
&:hover {
|
|
||||||
background-color: $oc-blue-7;
|
|
||||||
}
|
|
||||||
&:active {
|
|
||||||
background-color: $oc-blue-9;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
color: $oc-white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__items {
|
&__items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,226 +1,35 @@
|
|||||||
import { chunk } from "lodash";
|
import React, { useState } from "react";
|
||||||
import React, { useCallback, useState } from "react";
|
import { serializeLibraryAsJSON } from "../data/json";
|
||||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
|
||||||
import Library from "../data/library";
|
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import { LibraryItem, LibraryItems } from "../types";
|
||||||
AppState,
|
import { arrayToMap, chunk } from "../utils";
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawProps,
|
|
||||||
LibraryItem,
|
|
||||||
LibraryItems,
|
|
||||||
} from "../types";
|
|
||||||
import { arrayToMap, muteFSAbortError } from "../utils";
|
|
||||||
import { useDevice } from "./App";
|
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
|
||||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./LibraryMenuItems.scss";
|
import "./LibraryMenuItems.scss";
|
||||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import { fileOpen } from "../data/filesystem";
|
|
||||||
|
|
||||||
import { SidebarLockButton } from "./SidebarLockButton";
|
const CELLS_PER_ROW = 4;
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
const LibraryMenuItems = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
onAddToLibrary,
|
||||||
onInsertLibraryItems,
|
onInsertLibraryItems,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
theme,
|
|
||||||
setAppState,
|
|
||||||
appState,
|
|
||||||
libraryReturnUrl,
|
|
||||||
library,
|
|
||||||
files,
|
|
||||||
id,
|
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onSelectItems,
|
onSelectItems,
|
||||||
onPublish,
|
|
||||||
resetLibrary,
|
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
pendingElements: LibraryItem["elements"];
|
pendingElements: LibraryItem["elements"];
|
||||||
onRemoveFromLibrary: () => void;
|
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
files: BinaryFiles;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
appState: AppState;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
library: Library;
|
|
||||||
id: string;
|
|
||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
onPublish: () => void;
|
|
||||||
resetLibrary: () => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const renderRemoveLibAlert = useCallback(() => {
|
|
||||||
const content = selectedItems.length
|
|
||||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
|
||||||
: t("alerts.resetLibrary");
|
|
||||||
const title = selectedItems.length
|
|
||||||
? t("confirmDialog.removeItemsFromLib")
|
|
||||||
: t("confirmDialog.resetLibrary");
|
|
||||||
return (
|
|
||||||
<ConfirmDialog
|
|
||||||
onConfirm={() => {
|
|
||||||
if (selectedItems.length) {
|
|
||||||
onRemoveFromLibrary();
|
|
||||||
} else {
|
|
||||||
resetLibrary();
|
|
||||||
}
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowRemoveLibAlert(false);
|
|
||||||
}}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<p>{content}</p>
|
|
||||||
</ConfirmDialog>
|
|
||||||
);
|
|
||||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
|
||||||
|
|
||||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
|
||||||
const device = useDevice();
|
|
||||||
const renderLibraryActions = () => {
|
|
||||||
const itemsSelected = !!selectedItems.length;
|
|
||||||
const items = itemsSelected
|
|
||||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
|
||||||
: libraryItems;
|
|
||||||
const resetLabel = itemsSelected
|
|
||||||
? t("buttons.remove")
|
|
||||||
: t("buttons.resetLibrary");
|
|
||||||
return (
|
|
||||||
<div className="library-actions">
|
|
||||||
{!itemsSelected && (
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await library.updateLibrary({
|
|
||||||
libraryItems: fileOpen({
|
|
||||||
description: "Excalidraw library files",
|
|
||||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
|
||||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
|
||||||
/*
|
|
||||||
extensions: [".json", ".excalidrawlib"],
|
|
||||||
*/
|
|
||||||
}),
|
|
||||||
merge: true,
|
|
||||||
openLibraryMenu: true,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.name === "AbortError") {
|
|
||||||
console.warn(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="library-actions--load"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!items.length && (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportToFileIcon}
|
|
||||||
onClick={async () => {
|
|
||||||
const libraryItems = itemsSelected
|
|
||||||
? items
|
|
||||||
: await library.getLatestLibrary();
|
|
||||||
saveLibraryAsJSON(libraryItems)
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="library-actions--export"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton
|
|
||||||
key="reset"
|
|
||||||
type="button"
|
|
||||||
title={resetLabel}
|
|
||||||
aria-label={resetLabel}
|
|
||||||
icon={trash}
|
|
||||||
onClick={() => setShowRemoveLibAlert(true)}
|
|
||||||
className="library-actions--remove"
|
|
||||||
>
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{itemsSelected && (
|
|
||||||
<Tooltip label={t("hints.publishLibrary")}>
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
aria-label={t("buttons.publishLibrary")}
|
|
||||||
label={t("buttons.publishLibrary")}
|
|
||||||
icon={publishIcon}
|
|
||||||
className="library-actions--publish"
|
|
||||||
onClick={onPublish}
|
|
||||||
>
|
|
||||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
|
||||||
{selectedItems.length > 0 && (
|
|
||||||
<span className="library-actions-counter">
|
|
||||||
{selectedItems.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ToolButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{device.isMobile && (
|
|
||||||
<div className="library-menu-browse-button--mobile">
|
|
||||||
<a
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
|
||||||
|
|
||||||
const referrer =
|
|
||||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
|
||||||
|
|
||||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
LibraryItem["id"] | null
|
LibraryItem["id"] | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -297,7 +106,6 @@ const LibraryMenuItems = ({
|
|||||||
<Stack.Col key={params.key}>
|
<Stack.Col key={params.key}>
|
||||||
<LibraryUnit
|
<LibraryUnit
|
||||||
elements={params.item?.elements}
|
elements={params.item?.elements}
|
||||||
files={files}
|
|
||||||
isPending={!params.item?.id && !!params.item?.elements}
|
isPending={!params.item?.id && !!params.item?.elements}
|
||||||
onClick={params.onClick || (() => {})}
|
onClick={params.onClick || (() => {})}
|
||||||
id={params.item?.id || null}
|
id={params.item?.id || null}
|
||||||
@@ -373,56 +181,21 @@ const LibraryMenuItems = ({
|
|||||||
(item) => item.status === "published",
|
(item) => item.status === "published",
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLibraryHeader = () => {
|
return (
|
||||||
return (
|
<div
|
||||||
<>
|
className="library-menu-items-container"
|
||||||
<div className="layer-ui__library-header" key="library-header">
|
style={
|
||||||
{renderLibraryActions()}
|
publishedItems.length || unpublishedItems.length
|
||||||
{device.canDeviceFitSidebar && (
|
? {
|
||||||
<>
|
flex: "1 1 0",
|
||||||
<div className="layer-ui__sidebar-lock-button">
|
overflowY: "auto",
|
||||||
<SidebarLockButton
|
}
|
||||||
checked={appState.isLibraryMenuDocked}
|
: {
|
||||||
onChange={() => {
|
marginBottom: "2rem",
|
||||||
document
|
flex: 0,
|
||||||
.querySelector(".layer-ui__wrapper")
|
}
|
||||||
?.classList.add("animate");
|
}
|
||||||
const nextState = !appState.isLibraryMenuDocked;
|
>
|
||||||
setAppState({
|
|
||||||
isLibraryMenuDocked: nextState,
|
|
||||||
});
|
|
||||||
trackEvent(
|
|
||||||
"library",
|
|
||||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
|
||||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!device.isMobile && (
|
|
||||||
<div className="ToolIcon__icon__close">
|
|
||||||
<button
|
|
||||||
className="Modal__close"
|
|
||||||
onClick={() =>
|
|
||||||
setAppState({
|
|
||||||
isLibraryOpen: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={t("buttons.close")}
|
|
||||||
>
|
|
||||||
{close}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryMenuItems = () => {
|
|
||||||
return (
|
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
className="library-menu-items-container__items"
|
className="library-menu-items-container__items"
|
||||||
align="start"
|
align="start"
|
||||||
@@ -494,8 +267,8 @@ const LibraryMenuItems = ({
|
|||||||
|
|
||||||
<>
|
<>
|
||||||
{(publishedItems.length > 0 ||
|
{(publishedItems.length > 0 ||
|
||||||
(!device.isMobile &&
|
pendingElements.length > 0 ||
|
||||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
unpublishedItems.length > 0) && (
|
||||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||||
)}
|
)}
|
||||||
{publishedItems.length > 0 ? (
|
{publishedItems.length > 0 ? (
|
||||||
@@ -517,41 +290,6 @@ const LibraryMenuItems = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLibraryFooter = () => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="library-menu-browse-button"
|
|
||||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
|
||||||
VERSIONS.excalidrawLibrary
|
|
||||||
}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="library-menu-items-container"
|
|
||||||
style={
|
|
||||||
device.isMobile
|
|
||||||
? {
|
|
||||||
minHeight: "200px",
|
|
||||||
maxHeight: "70vh",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
|
||||||
{renderLibraryHeader()}
|
|
||||||
{renderLibraryMenuItems()}
|
|
||||||
{!device.isMobile && renderLibraryFooter()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import oc from "open-color";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDevice } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { exportToSvg } from "../scene/export";
|
import { exportToSvg } from "../scene/export";
|
||||||
import { BinaryFiles, LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import "./LibraryUnit.scss";
|
import "./LibraryUnit.scss";
|
||||||
import { CheckboxItem } from "./CheckboxItem";
|
import { CheckboxItem } from "./CheckboxItem";
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ const PLUS_ICON = (
|
|||||||
export const LibraryUnit = ({
|
export const LibraryUnit = ({
|
||||||
id,
|
id,
|
||||||
elements,
|
elements,
|
||||||
files,
|
|
||||||
isPending,
|
isPending,
|
||||||
onClick,
|
onClick,
|
||||||
selected,
|
selected,
|
||||||
@@ -32,7 +31,6 @@ export const LibraryUnit = ({
|
|||||||
}: {
|
}: {
|
||||||
id: LibraryItem["id"] | /** for pending item */ null;
|
id: LibraryItem["id"] | /** for pending item */ null;
|
||||||
elements?: LibraryItem["elements"];
|
elements?: LibraryItem["elements"];
|
||||||
files: BinaryFiles;
|
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -56,7 +54,7 @@ export const LibraryUnit = ({
|
|||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
},
|
},
|
||||||
files,
|
null,
|
||||||
);
|
);
|
||||||
node.innerHTML = svg.outerHTML;
|
node.innerHTML = svg.outerHTML;
|
||||||
})();
|
})();
|
||||||
@@ -64,7 +62,7 @@ export const LibraryUnit = ({
|
|||||||
return () => {
|
return () => {
|
||||||
node.innerHTML = "";
|
node.innerHTML = "";
|
||||||
};
|
};
|
||||||
}, [elements, files]);
|
}, [elements]);
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useDevice().isMobile;
|
const isMobile = useDevice().isMobile;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { THEME } from "../constants";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||||
|
delay,
|
||||||
|
theme,
|
||||||
|
}) => {
|
||||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,7 +26,11 @@ export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="LoadingMessage">
|
<div
|
||||||
|
className={clsx("LoadingMessage", {
|
||||||
|
"LoadingMessage--dark": theme === THEME.DARK,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState } from "../types";
|
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
@@ -18,6 +18,8 @@ import { UserList } from "./UserList";
|
|||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { LibraryButton } from "./LibraryButton";
|
import { LibraryButton } from "./LibraryButton";
|
||||||
import { PenModeButton } from "./PenModeButton";
|
import { PenModeButton } from "./PenModeButton";
|
||||||
|
import { Stats } from "./Stats";
|
||||||
|
import { actionToggleStats } from "../actions";
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -26,7 +28,6 @@ type MobileMenuProps = {
|
|||||||
renderImageExportDialog: () => React.ReactNode;
|
renderImageExportDialog: () => React.ReactNode;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
libraryMenu: JSX.Element | null;
|
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onPenModeToggle: () => void;
|
onPenModeToggle: () => void;
|
||||||
@@ -36,19 +37,19 @@ type MobileMenuProps = {
|
|||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
showThemeBtn: boolean;
|
|
||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderTopRightUI?: (
|
renderTopRightUI?: (
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
renderStats: () => JSX.Element | null;
|
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||||
|
renderSidebars: () => JSX.Element | null;
|
||||||
|
device: Device;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
libraryMenu,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
renderJSONExportDialog,
|
renderJSONExportDialog,
|
||||||
renderImageExportDialog,
|
renderImageExportDialog,
|
||||||
@@ -59,10 +60,11 @@ export const MobileMenu = ({
|
|||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
showThemeBtn,
|
|
||||||
onImageAction,
|
onImageAction,
|
||||||
renderTopRightUI,
|
renderTopRightUI,
|
||||||
renderStats,
|
renderCustomStats,
|
||||||
|
renderSidebars,
|
||||||
|
device,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@@ -107,11 +109,15 @@ export const MobileMenu = ({
|
|||||||
penDetected={appState.penDetected}
|
penDetected={appState.penDetected}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
<HintViewer
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
isMobile={true}
|
||||||
|
device={device}
|
||||||
|
/>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -119,7 +125,6 @@ export const MobileMenu = ({
|
|||||||
const renderAppToolbar = () => {
|
const renderAppToolbar = () => {
|
||||||
// Render eraser conditionally in mobile
|
// Render eraser conditionally in mobile
|
||||||
const showEraser =
|
const showEraser =
|
||||||
!appState.viewModeEnabled &&
|
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
getSelectedElements(elements, appState).length === 0;
|
getSelectedElements(elements, appState).length === 0;
|
||||||
|
|
||||||
@@ -138,11 +143,11 @@ export const MobileMenu = ({
|
|||||||
|
|
||||||
{actionManager.renderAction("undo")}
|
{actionManager.renderAction("undo")}
|
||||||
{actionManager.renderAction("redo")}
|
{actionManager.renderAction("redo")}
|
||||||
{showEraser && actionManager.renderAction("eraser")}
|
{showEraser
|
||||||
|
? actionManager.renderAction("eraser")
|
||||||
{actionManager.renderAction(
|
: actionManager.renderAction(
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
)}
|
)}
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -170,21 +175,25 @@ export const MobileMenu = ({
|
|||||||
onClick={onCollabButtonClick}
|
onClick={onCollabButtonClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{
|
{<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />}
|
||||||
<BackgroundPickerAndDarkModeToggle
|
|
||||||
actionManager={actionManager}
|
|
||||||
appState={appState}
|
|
||||||
setAppState={setAppState}
|
|
||||||
showThemeBtn={showThemeBtn}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{renderSidebars()}
|
||||||
{!appState.viewModeEnabled && renderToolbar()}
|
{!appState.viewModeEnabled && renderToolbar()}
|
||||||
{renderStats()}
|
{!appState.openMenu && appState.showStats && (
|
||||||
|
<Stats
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
elements={elements}
|
||||||
|
onClose={() => {
|
||||||
|
actionManager.executeAction(actionToggleStats);
|
||||||
|
}}
|
||||||
|
renderCustomStats={renderCustomStats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="App-bottom-bar"
|
className="App-bottom-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -221,7 +230,6 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
activeTool={appState.activeTool.type}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -229,7 +237,7 @@ export const MobileMenu = ({
|
|||||||
{renderAppToolbar()}
|
{renderAppToolbar()}
|
||||||
{appState.scrolledOutside &&
|
{appState.scrolledOutside &&
|
||||||
!appState.openMenu &&
|
!appState.openMenu &&
|
||||||
!appState.isLibraryOpen && (
|
appState.openSidebar !== "library" && (
|
||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
},
|
},
|
||||||
null, // files
|
null, // files
|
||||||
);
|
);
|
||||||
|
previewNode.replaceChildren();
|
||||||
previewNode.appendChild(svg);
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
previewNode.removeChild(svg);
|
previewNode.replaceChildren();
|
||||||
};
|
};
|
||||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export const Popover = ({
|
|||||||
if (fitInViewport && popoverRef.current) {
|
if (fitInViewport && popoverRef.current) {
|
||||||
const element = popoverRef.current;
|
const element = popoverRef.current;
|
||||||
const { x, y, width, height } = element.getBoundingClientRect();
|
const { x, y, width, height } = element.getBoundingClientRect();
|
||||||
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
|
|
||||||
|
|
||||||
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
||||||
if (x + width - offsetLeft > viewportWidth) {
|
if (x + width - offsetLeft > viewportWidth) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@import "open-color/open-color";
|
||||||
|
@import "../../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.layer-ui__sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sat);
|
||||||
|
bottom: var(--sab);
|
||||||
|
right: var(--sar);
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
margin: var(--space-factor);
|
||||||
|
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||||
|
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.Island {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon__close {
|
||||||
|
.Modal__close {
|
||||||
|
width: calc(var(--space-factor) * 7);
|
||||||
|
height: calc(var(--space-factor) * 7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Island {
|
||||||
|
--padding: 0;
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
|
position: relative;
|
||||||
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__sidebar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2px 0 15px 0;
|
||||||
|
&:empty {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__sidebar__header__buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__sidebar-dock-button {
|
||||||
|
@include toolbarButtonColorStates;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
|
||||||
|
.ToolIcon_type_floating .ToolIcon__icon {
|
||||||
|
width: calc(var(--space-factor) * 7);
|
||||||
|
height: calc(var(--space-factor) * 7);
|
||||||
|
svg {
|
||||||
|
// mirror
|
||||||
|
transform: scale(-1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_checkbox {
|
||||||
|
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
queryAllByTestId,
|
||||||
|
queryByTestId,
|
||||||
|
render,
|
||||||
|
waitFor,
|
||||||
|
withExcalidrawDimensions,
|
||||||
|
} from "../../tests/test-utils";
|
||||||
|
|
||||||
|
describe("Sidebar", () => {
|
||||||
|
it("should render custom sidebar", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar>
|
||||||
|
<div id="test-sidebar-content">42</div>
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render custom sidebar header", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar>
|
||||||
|
<Sidebar.Header>
|
||||||
|
<div id="test-sidebar-header-content">42</div>
|
||||||
|
</Sidebar.Header>
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = container.querySelector("#test-sidebar-header-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
// make sure we don't render the default fallback header,
|
||||||
|
// just the custom one
|
||||||
|
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render only one sidebar and prefer the custom one", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar>
|
||||||
|
<div id="test-sidebar-content">42</div>
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// make sure the custom sidebar is rendered
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
|
||||||
|
// make sure only one sidebar is rendered
|
||||||
|
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||||
|
expect(sidebars.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always render custom sidebar with close button & close on click", async () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar className="test-sidebar" onClose={onClose}>
|
||||||
|
hello
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||||
|
expect(closeButton).not.toBe(null);
|
||||||
|
|
||||||
|
fireEvent.click(closeButton!.querySelector("button")!);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar className="test-sidebar">hello</Sidebar>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
// should show dock button when the sidebar fits to be docked
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
expect(closeButton).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// should not show dock button when the sidebar does not fit to be docked
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
expect(closeButton).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support controlled docking", async () => {
|
||||||
|
let _setDockable: (dockable: boolean) => void = null!;
|
||||||
|
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
const [dockable, setDockable] = React.useState(false);
|
||||||
|
_setDockable = setDockable;
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar
|
||||||
|
className="test-sidebar"
|
||||||
|
docked={false}
|
||||||
|
dockable={dockable}
|
||||||
|
>
|
||||||
|
hello
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||||
|
// should not show dock button when `dockable` is `false`
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
_setDockable(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
expect(closeButton).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// should show dock button when `dockable` is `true`, even if `docked`
|
||||||
|
// prop is set
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
_setDockable(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
expect(closeButton).not.toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support controlled docking", async () => {
|
||||||
|
let _setDocked: (docked?: boolean) => void = null!;
|
||||||
|
|
||||||
|
const CustomExcalidraw = () => {
|
||||||
|
const [docked, setDocked] = React.useState<boolean | undefined>();
|
||||||
|
_setDocked = setDocked;
|
||||||
|
return (
|
||||||
|
<Excalidraw
|
||||||
|
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar className="test-sidebar" docked={docked}>
|
||||||
|
hello
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||||
|
const dockButton = await waitFor(() => {
|
||||||
|
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||||
|
expect(sidebar).not.toBe(null);
|
||||||
|
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
|
||||||
|
expect(dockBotton).not.toBe(null);
|
||||||
|
return dockBotton!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dockButtonInput = dockButton.querySelector("input")!;
|
||||||
|
|
||||||
|
// should not show dock button when `dockable` is `false`
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
expect(h.state.isSidebarDocked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(dockButtonInput);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.isSidebarDocked).toBe(true);
|
||||||
|
expect(dockButtonInput).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButtonInput);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.isSidebarDocked).toBe(false);
|
||||||
|
expect(dockButtonInput).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
// shouldn't update `appState.isSidebarDocked` when the sidebar
|
||||||
|
// is controlled (`docked` prop is set), as host apps should handle
|
||||||
|
// the state themselves
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
_setDocked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dockButtonInput).toBeChecked();
|
||||||
|
expect(h.state.isSidebarDocked).toBe(false);
|
||||||
|
expect(dockButtonInput).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButtonInput);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.isSidebarDocked).toBe(false);
|
||||||
|
expect(dockButtonInput).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
// the `appState.isSidebarDocked` should remain untouched when
|
||||||
|
// `props.docked` is set to `false`, and user toggles
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
_setDocked(false);
|
||||||
|
h.setState({ isSidebarDocked: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.isSidebarDocked).toBe(true);
|
||||||
|
expect(dockButtonInput).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(dockButtonInput);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dockButtonInput).not.toBeChecked();
|
||||||
|
expect(h.state.isSidebarDocked).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle sidebar using props.toggleMenu()", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw
|
||||||
|
renderSidebar={() => (
|
||||||
|
<Sidebar>
|
||||||
|
<div id="test-sidebar-content">42</div>
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// sidebar isn't rendered initially
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle sidebar on
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle sidebar off
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// force-toggle sidebar off (=> still hidden)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// force-toggle sidebar on
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||||
|
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle library (= hide custom sidebar)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleMenu("library")).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).toBe(null);
|
||||||
|
|
||||||
|
// make sure only one sidebar is rendered
|
||||||
|
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||||
|
expect(sidebars.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import { Island } from ".././Island";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../../jotai";
|
||||||
|
import {
|
||||||
|
SidebarPropsContext,
|
||||||
|
SidebarProps,
|
||||||
|
SidebarPropsContextValue,
|
||||||
|
} from "./common";
|
||||||
|
|
||||||
|
import { SidebarHeaderComponents } from "./SidebarHeader";
|
||||||
|
|
||||||
|
import "./Sidebar.scss";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
import { updateObject } from "../../utils";
|
||||||
|
|
||||||
|
/** using a counter instead of boolean to handle race conditions where
|
||||||
|
* the host app may render (mount/unmount) multiple different sidebar */
|
||||||
|
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||||
|
|
||||||
|
export const Sidebar = Object.assign(
|
||||||
|
forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
onDock,
|
||||||
|
docked,
|
||||||
|
dockable = true,
|
||||||
|
className,
|
||||||
|
__isInternal,
|
||||||
|
}: SidebarProps<{
|
||||||
|
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||||
|
// It indicates that this sidebar should have lower precedence over host
|
||||||
|
// sidebars, if both are open.
|
||||||
|
/** @private internal */
|
||||||
|
__isInternal?: boolean;
|
||||||
|
}>,
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
||||||
|
hostSidebarCountersAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
|
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (docked === undefined) {
|
||||||
|
// ugly hack to get initial state out of AppState without subscribing
|
||||||
|
// to it as a whole (once we have granular subscriptions, we'll move
|
||||||
|
// to that)
|
||||||
|
//
|
||||||
|
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||||
|
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||||
|
setAppState((state) => {
|
||||||
|
setIsDockedFallback(state.isSidebarDocked);
|
||||||
|
// bail from update
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [setAppState, docked]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!__isInternal) {
|
||||||
|
setHostSidebarCounters((s) => ({
|
||||||
|
rendered: s.rendered + 1,
|
||||||
|
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
||||||
|
}));
|
||||||
|
return () => {
|
||||||
|
setHostSidebarCounters((s) => ({
|
||||||
|
rendered: s.rendered - 1,
|
||||||
|
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
||||||
|
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onCloseRef.current?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
||||||
|
headerPropsRef.current.onClose = () => {
|
||||||
|
setAppState({ openSidebar: null });
|
||||||
|
};
|
||||||
|
headerPropsRef.current.onDock = (isDocked) => {
|
||||||
|
if (docked === undefined) {
|
||||||
|
setAppState({ isSidebarDocked: isDocked });
|
||||||
|
setIsDockedFallback(isDocked);
|
||||||
|
}
|
||||||
|
onDock?.(isDocked);
|
||||||
|
};
|
||||||
|
// renew the ref object if the following props change since we want to
|
||||||
|
// rerender. We can't pass down as component props manually because
|
||||||
|
// the <Sidebar.Header/> can be rendered upsream.
|
||||||
|
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||||
|
docked: docked ?? isDockedFallback,
|
||||||
|
dockable,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Island
|
||||||
|
padding={2}
|
||||||
|
className={clsx("layer-ui__sidebar", className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||||
|
<SidebarHeaderComponents.Context>
|
||||||
|
<SidebarHeaderComponents.Component __isFallback />
|
||||||
|
{children}
|
||||||
|
</SidebarHeaderComponents.Context>
|
||||||
|
</SidebarPropsContext.Provider>
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
Header: SidebarHeaderComponents.Component,
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { useDevice } from "../App";
|
||||||
|
import { SidebarPropsContext } from "./common";
|
||||||
|
import { close } from "../icons";
|
||||||
|
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
|
||||||
|
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||||
|
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||||
|
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SidebarDockButton = (props: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange?(): void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
||||||
|
<Tooltip label={t("labels.sidebarLock")}>
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||||
|
`ToolIcon_size_medium`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="ToolIcon_type_checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={props.onChange}
|
||||||
|
checked={props.checked}
|
||||||
|
aria-label={t("labels.sidebarLock")}
|
||||||
|
/>{" "}
|
||||||
|
<div className="ToolIcon__icon" tabIndex={0}>
|
||||||
|
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||||
|
</div>{" "}
|
||||||
|
</label>{" "}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SidebarHeader: React.FC<{
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ children, className }) => {
|
||||||
|
const device = useDevice();
|
||||||
|
const props = useContext(SidebarPropsContext);
|
||||||
|
|
||||||
|
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
||||||
|
const renderCloseButton = !!props.onClose;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("layer-ui__sidebar__header", className)}
|
||||||
|
data-testid="sidebar-header"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{(renderDockButton || renderCloseButton) && (
|
||||||
|
<div className="layer-ui__sidebar__header__buttons">
|
||||||
|
{renderDockButton && (
|
||||||
|
<SidebarDockButton
|
||||||
|
checked={!!props.docked}
|
||||||
|
onChange={() => {
|
||||||
|
document
|
||||||
|
.querySelector(".layer-ui__wrapper")
|
||||||
|
?.classList.add("animate");
|
||||||
|
|
||||||
|
props.onDock?.(!props.docked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderCloseButton && (
|
||||||
|
<div className="ToolIcon__icon__close" data-testid="sidebar-close">
|
||||||
|
<button
|
||||||
|
className="Modal__close"
|
||||||
|
onClick={props.onClose}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
>
|
||||||
|
{close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
export const SidebarHeaderComponents = { Context, Component };
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type SidebarProps<P = {}> = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Called on sidebar close (either by user action or by the editor).
|
||||||
|
*/
|
||||||
|
onClose?: () => void | boolean;
|
||||||
|
/** if not supplied, sidebar won't be dockable */
|
||||||
|
onDock?: (docked: boolean) => void;
|
||||||
|
docked?: boolean;
|
||||||
|
dockable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
} & P;
|
||||||
|
|
||||||
|
export type SidebarPropsContextValue = Pick<
|
||||||
|
SidebarProps,
|
||||||
|
"onClose" | "onDock" | "docked" | "dockable"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const SidebarPropsContext =
|
||||||
|
React.createContext<SidebarPropsContextValue>({});
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
@import "../css/variables.module";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
.layer-ui__sidebar-lock-button {
|
|
||||||
@include toolbarButtonColorStates;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
}
|
|
||||||
.ToolIcon_type_floating .side_lock_icon {
|
|
||||||
width: calc(var(--space-factor) * 7);
|
|
||||||
height: calc(var(--space-factor) * 7);
|
|
||||||
svg {
|
|
||||||
// mirror
|
|
||||||
transform: scale(-1, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ToolIcon_type_checkbox {
|
|
||||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import "./ToolIcon.scss";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ToolButtonSize } from "./ToolButton";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
import "./SidebarLockButton.scss";
|
|
||||||
|
|
||||||
type SidebarLockIconProps = {
|
|
||||||
checked: boolean;
|
|
||||||
onChange?(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
|
||||||
|
|
||||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
|
||||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
|
||||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("labels.sidebarLock")}>
|
|
||||||
<label
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
|
||||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="ToolIcon_type_checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={props.onChange}
|
|
||||||
checked={props.checked}
|
|
||||||
aria-label={t("labels.sidebarLock")}
|
|
||||||
/>{" "}
|
|
||||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
|
||||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
|
||||||
</div>{" "}
|
|
||||||
</label>{" "}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useDevice } from "../components/App";
|
|
||||||
import { getTargetElements } from "../scene";
|
import { getTargetElements } from "../scene";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import { close } from "./icons";
|
import { close } from "./icons";
|
||||||
@@ -16,13 +15,10 @@ export const Stats = (props: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
|
||||||
const boundingBox = getCommonBounds(props.elements);
|
const boundingBox = getCommonBounds(props.elements);
|
||||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||||
if (device.isMobile && props.appState.openMenu) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="Stats">
|
<div className="Stats">
|
||||||
<Island padding={2}>
|
<Island padding={2}>
|
||||||
|
|||||||
@@ -187,3 +187,5 @@ ToolButton.defaultProps = {
|
|||||||
className: "",
|
className: "",
|
||||||
size: "medium",
|
size: "medium",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ToolButton.displayName = "ToolButton";
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
createContext,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
|
||||||
|
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
||||||
|
|
||||||
|
const DefaultComponentContext = createContext<ContextValue>([
|
||||||
|
false,
|
||||||
|
() => {},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
|
||||||
|
const contextValue: ContextValue = useMemo(
|
||||||
|
() => [isRenderedUpstream, setIsRenderedUpstream],
|
||||||
|
[isRenderedUpstream],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultComponentContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</DefaultComponentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultComponent = (
|
||||||
|
props: P & {
|
||||||
|
// indicates whether component should render when not rendered upstream
|
||||||
|
/** @private internal */
|
||||||
|
__isFallback?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
|
||||||
|
DefaultComponentContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!props.__isFallback) {
|
||||||
|
setIsRenderedUpstream(true);
|
||||||
|
return () => setIsRenderedUpstream(false);
|
||||||
|
}
|
||||||
|
}, [props.__isFallback, setIsRenderedUpstream]);
|
||||||
|
|
||||||
|
if (props.__isFallback && isRenderedUpstream) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component {...props} />;
|
||||||
|
};
|
||||||
|
if (Component.name) {
|
||||||
|
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
|
||||||
|
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ComponentContext, DefaultComponent] as const;
|
||||||
|
};
|
||||||
@@ -99,6 +99,9 @@ export const MIME_TYPES = {
|
|||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
ico: "image/x-icon",
|
||||||
binary: "application/octet-stream",
|
binary: "application/octet-stream",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -119,6 +122,7 @@ export const TITLE_TIMEOUT = 10000;
|
|||||||
export const VERSION_TIMEOUT = 30000;
|
export const VERSION_TIMEOUT = 30000;
|
||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
|
export const MIN_ZOOM = 0.1;
|
||||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||||
|
|
||||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
@@ -149,7 +153,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
export: { saveFileToDisk: true },
|
export: { saveFileToDisk: true },
|
||||||
loadScene: true,
|
loadScene: true,
|
||||||
saveToActiveFile: true,
|
saveToActiveFile: true,
|
||||||
theme: true,
|
toggleTheme: null,
|
||||||
saveAsImage: true,
|
saveAsImage: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -180,6 +184,9 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
|||||||
MIME_TYPES.jpg,
|
MIME_TYPES.jpg,
|
||||||
MIME_TYPES.svg,
|
MIME_TYPES.svg,
|
||||||
MIME_TYPES.gif,
|
MIME_TYPES.gif,
|
||||||
|
MIME_TYPES.webp,
|
||||||
|
MIME_TYPES.bmp,
|
||||||
|
MIME_TYPES.ico,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
@@ -201,8 +208,18 @@ export const VERTICAL_ALIGN = {
|
|||||||
BOTTOM: "bottom",
|
BOTTOM: "bottom",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TEXT_ALIGN = {
|
||||||
|
LEFT: "left",
|
||||||
|
CENTER: "center",
|
||||||
|
RIGHT: "right",
|
||||||
|
};
|
||||||
|
|
||||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||||
|
|
||||||
export const COOKIES = {
|
export const COOKIES = {
|
||||||
AUTH_STATE_COOKIE: "excplus-auth",
|
AUTH_STATE_COOKIE: "excplus-auth",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||||
|
* collaboration */
|
||||||
|
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -30,3 +32,8 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.LoadingMessage--dark {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ced4da;
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export const getFileHandle = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attemps to detect if a buffer is a valid image by checking its leading bytes
|
* attempts to detect if a buffer is a valid image by checking its leading bytes
|
||||||
*/
|
*/
|
||||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
||||||
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
||||||
@@ -396,7 +396,7 @@ export const createFile = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** attemps to detect correct mimeType if none is set, or if an image
|
/** attempts to detect correct mimeType if none is set, or if an image
|
||||||
* has an incorrect extension.
|
* has an incorrect extension.
|
||||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||||
export const normalizeFile = async (file: File) => {
|
export const normalizeFile = async (file: File) => {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class Library {
|
|||||||
defaultStatus?: "unpublished" | "published";
|
defaultStatus?: "unpublished" | "published";
|
||||||
}): Promise<LibraryItems> => {
|
}): Promise<LibraryItems> => {
|
||||||
if (openLibraryMenu) {
|
if (openLibraryMenu) {
|
||||||
this.app.setState({ isLibraryOpen: true });
|
this.app.setState({ openSidebar: "library" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setLibrary(() => {
|
return this.setLibrary(() => {
|
||||||
@@ -365,38 +365,56 @@ export const useHandleLibrary = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const importLibraryFromURL = ({
|
const importLibraryFromURL = async ({
|
||||||
libraryUrl,
|
libraryUrl,
|
||||||
idToken,
|
idToken,
|
||||||
}: {
|
}: {
|
||||||
libraryUrl: string;
|
libraryUrl: string;
|
||||||
idToken: string | null;
|
idToken: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
try {
|
||||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
const blob = await request.blob();
|
||||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
resolve(blob);
|
||||||
const query = new URLSearchParams(window.location.search);
|
} catch (error: any) {
|
||||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
reject(error);
|
||||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
excalidrawAPI.updateLibrary({
|
|
||||||
libraryItems: new Promise<Blob>(async (resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
|
||||||
const blob = await request.blob();
|
|
||||||
resolve(blob);
|
|
||||||
} catch (error: any) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
prompt: idToken !== excalidrawAPI.id,
|
|
||||||
merge: true,
|
|
||||||
defaultStatus: "published",
|
|
||||||
openLibraryMenu: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldPrompt = idToken !== excalidrawAPI.id;
|
||||||
|
|
||||||
|
// wait for the tab to be focused before continuing in case we'll prompt
|
||||||
|
// for confirmation
|
||||||
|
await (shouldPrompt && document.hidden
|
||||||
|
? new Promise<void>((resolve) => {
|
||||||
|
window.addEventListener("focus", () => resolve(), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await excalidrawAPI.updateLibrary({
|
||||||
|
libraryItems: libraryPromise,
|
||||||
|
prompt: shouldPrompt,
|
||||||
|
merge: true,
|
||||||
|
defaultStatus: "published",
|
||||||
|
openLibraryMenu: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||||
|
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||||
|
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||||
|
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||||
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||||
|
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onHashChange = (event: HashChangeEvent) => {
|
const onHashChange = (event: HashChangeEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
NormalizedZoomValue,
|
NormalizedZoomValue,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { ImportedDataState } from "./types";
|
import { ImportedDataState, LegacyAppState } from "./types";
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
getNormalizedDimensions,
|
getNormalizedDimensions,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
|
refreshTextDimensions,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { isLinearElementType } from "../element/typeChecks";
|
import { isLinearElementType } from "../element/typeChecks";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
|
PRECEDING_ELEMENT_KEY,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
@@ -67,13 +69,16 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
T extends ExcalidrawElement,
|
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
customData?: ExcalidrawElement["customData"];
|
||||||
>(
|
|
||||||
element: Required<T> & {
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
|
/** metadata that may be present in elements during collaboration */
|
||||||
|
[PRECEDING_ELEMENT_KEY]?: string;
|
||||||
},
|
},
|
||||||
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
extra: Pick<
|
extra: Pick<
|
||||||
T,
|
T,
|
||||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||||
@@ -82,7 +87,9 @@ const restoreElementWithProperties = <
|
|||||||
> &
|
> &
|
||||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||||
): T => {
|
): T => {
|
||||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||||
|
[PRECEDING_ELEMENT_KEY]?: string;
|
||||||
|
} = {
|
||||||
type: extra.type || element.type,
|
type: extra.type || element.type,
|
||||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||||
// newly added elements
|
// newly added elements
|
||||||
@@ -115,6 +122,14 @@ const restoreElementWithProperties = <
|
|||||||
locked: element.locked ?? false,
|
locked: element.locked ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ("customData" in element) {
|
||||||
|
base.customData = element.customData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRECEDING_ELEMENT_KEY in element) {
|
||||||
|
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...getNormalizedDimensions(base),
|
...getNormalizedDimensions(base),
|
||||||
@@ -124,6 +139,7 @@ const restoreElementWithProperties = <
|
|||||||
|
|
||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
refreshDimensions = true,
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text":
|
case "text":
|
||||||
@@ -136,7 +152,7 @@ const restoreElement = (
|
|||||||
fontSize = parseInt(fontPx, 10);
|
fontSize = parseInt(fontPx, 10);
|
||||||
fontFamily = getFontFamilyByName(_fontFamily);
|
fontFamily = getFontFamilyByName(_fontFamily);
|
||||||
}
|
}
|
||||||
return restoreElementWithProperties(element, {
|
element = restoreElementWithProperties(element, {
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
text: element.text ?? "",
|
text: element.text ?? "",
|
||||||
@@ -146,6 +162,11 @@ const restoreElement = (
|
|||||||
containerId: element.containerId ?? null,
|
containerId: element.containerId ?? null,
|
||||||
originalText: element.originalText || element.text,
|
originalText: element.originalText || element.text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (refreshDimensions) {
|
||||||
|
element = { ...element, ...refreshTextDimensions(element) };
|
||||||
|
}
|
||||||
|
return element;
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
return restoreElementWithProperties(element, {
|
return restoreElementWithProperties(element, {
|
||||||
points: element.points,
|
points: element.points,
|
||||||
@@ -218,13 +239,17 @@ export const restoreElements = (
|
|||||||
elements: ImportedDataState["elements"],
|
elements: ImportedDataState["elements"],
|
||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
|
refreshDimensions = true,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
return (elements || []).reduce((elements, element) => {
|
return (elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
let migratedElement: ExcalidrawElement | null = restoreElement(
|
||||||
|
element,
|
||||||
|
refreshDimensions,
|
||||||
|
);
|
||||||
if (migratedElement) {
|
if (migratedElement) {
|
||||||
const localElement = localElementsMap?.get(element.id);
|
const localElement = localElementsMap?.get(element.id);
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
@@ -237,6 +262,43 @@ export const restoreElements = (
|
|||||||
}, [] as ExcalidrawElement[]);
|
}, [] as ExcalidrawElement[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coalesceAppStateValue = <
|
||||||
|
T extends keyof ReturnType<typeof getDefaultAppState>,
|
||||||
|
>(
|
||||||
|
key: T,
|
||||||
|
appState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||||
|
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||||
|
) => {
|
||||||
|
const value = appState[key];
|
||||||
|
// NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
|
||||||
|
return value !== undefined ? value! : defaultAppState[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const LegacyAppStateMigrations: {
|
||||||
|
[K in keyof LegacyAppState]: (
|
||||||
|
ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||||
|
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||||
|
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||||
|
} = {
|
||||||
|
isLibraryOpen: (appState, defaultAppState) => {
|
||||||
|
return [
|
||||||
|
"openSidebar",
|
||||||
|
"isLibraryOpen" in appState
|
||||||
|
? appState.isLibraryOpen
|
||||||
|
? "library"
|
||||||
|
: null
|
||||||
|
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
isLibraryMenuDocked: (appState, defaultAppState) => {
|
||||||
|
return [
|
||||||
|
"isSidebarDocked",
|
||||||
|
appState.isLibraryMenuDocked ??
|
||||||
|
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const restoreAppState = (
|
export const restoreAppState = (
|
||||||
appState: ImportedDataState["appState"],
|
appState: ImportedDataState["appState"],
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
@@ -244,11 +306,30 @@ export const restoreAppState = (
|
|||||||
appState = appState || {};
|
appState = appState || {};
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
const nextAppState = {} as typeof defaultAppState;
|
const nextAppState = {} as typeof defaultAppState;
|
||||||
|
|
||||||
|
// first, migrate all legacy AppState properties to new ones. We do it
|
||||||
|
// in one go before migrate the rest of the properties in case the new ones
|
||||||
|
// depend on checking any other key (i.e. they are coupled)
|
||||||
|
for (const legacyKey of Object.keys(
|
||||||
|
LegacyAppStateMigrations,
|
||||||
|
) as (keyof typeof LegacyAppStateMigrations)[]) {
|
||||||
|
if (legacyKey in appState) {
|
||||||
|
const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
|
||||||
|
appState,
|
||||||
|
defaultAppState,
|
||||||
|
);
|
||||||
|
(nextAppState as any)[nextKey] = nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||||
keyof typeof defaultAppState,
|
keyof typeof defaultAppState,
|
||||||
any,
|
any,
|
||||||
][]) {
|
][]) {
|
||||||
|
// if AppState contains a legacy key, prefer that one and migrate its
|
||||||
|
// value to the new one
|
||||||
const suppliedValue = appState[key];
|
const suppliedValue = appState[key];
|
||||||
|
|
||||||
const localValue = localAppState ? localAppState[key] : undefined;
|
const localValue = localAppState ? localAppState[key] : undefined;
|
||||||
(nextAppState as any)[key] =
|
(nextAppState as any)[key] =
|
||||||
suppliedValue !== undefined
|
suppliedValue !== undefined
|
||||||
@@ -285,9 +366,12 @@ export const restoreAppState = (
|
|||||||
: appState.zoom || defaultAppState.zoom,
|
: appState.zoom || defaultAppState.zoom,
|
||||||
// when sidebar docked and user left it open in last session,
|
// when sidebar docked and user left it open in last session,
|
||||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||||
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
openSidebar:
|
||||||
? nextAppState.isLibraryOpen
|
nextAppState.openSidebar === "library"
|
||||||
: false,
|
? nextAppState.isSidebarDocked
|
||||||
|
? "library"
|
||||||
|
: null
|
||||||
|
: nextAppState.openSidebar,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,7 +387,7 @@ export const restore = (
|
|||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
): RestoredDataState => {
|
): RestoredDataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data?.elements, localElements),
|
elements: restoreElements(data?.elements, localElements, true),
|
||||||
appState: restoreAppState(data?.appState, localAppState || null),
|
appState: restoreAppState(data?.appState, localAppState || null),
|
||||||
files: data?.files || {},
|
files: data?.files || {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,12 +17,32 @@ export interface ExportedDataState {
|
|||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of legacy AppState keys, with values of:
|
||||||
|
* [<legacy type>, <new AppState proeprty>]
|
||||||
|
*
|
||||||
|
* This is a helper type used in downstream abstractions.
|
||||||
|
* Don't consume on its own.
|
||||||
|
*/
|
||||||
|
export type LegacyAppState = {
|
||||||
|
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||||
|
isLibraryOpen: [boolean, "openSidebar"];
|
||||||
|
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||||
|
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
|
||||||
|
};
|
||||||
|
|
||||||
export interface ImportedDataState {
|
export interface ImportedDataState {
|
||||||
type?: string;
|
type?: string;
|
||||||
version?: number;
|
version?: number;
|
||||||
source?: string;
|
source?: string;
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: Readonly<Partial<AppState>> | null;
|
appState?: Readonly<
|
||||||
|
Partial<
|
||||||
|
AppState & {
|
||||||
|
[T in keyof LegacyAppState]: LegacyAppState[T][0];
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> | null;
|
||||||
scrollToContent?: boolean;
|
scrollToContent?: boolean;
|
||||||
libraryItems?: LibraryItems_anyVersion;
|
libraryItems?: LibraryItems_anyVersion;
|
||||||
files?: BinaryFiles;
|
files?: BinaryFiles;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { getElementAbsoluteCoords } from "./";
|
|||||||
|
|
||||||
import "./Hyperlink.scss";
|
import "./Hyperlink.scss";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
|
import { useExcalidrawAppState } from "../components/App";
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
const CONTAINER_WIDTH = 320;
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
@@ -48,15 +49,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
|||||||
|
|
||||||
export const Hyperlink = ({
|
export const Hyperlink = ({
|
||||||
element,
|
element,
|
||||||
appState,
|
|
||||||
setAppState,
|
setAppState,
|
||||||
onLinkOpen,
|
onLinkOpen,
|
||||||
}: {
|
}: {
|
||||||
element: NonDeletedExcalidrawElement;
|
element: NonDeletedExcalidrawElement;
|
||||||
appState: AppState;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||||
}) => {
|
}) => {
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
|
||||||
const linkVal = element.link || "";
|
const linkVal = element.link || "";
|
||||||
|
|
||||||
const [inputVal, setInputVal] = useState(linkVal);
|
const [inputVal, setInputVal] = useState(linkVal);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const _ce = ({
|
|||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
roughness: 1,
|
roughness: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -106,7 +106,7 @@ describe("getElementBounds", () => {
|
|||||||
} as ExcalidrawLinearElement);
|
} as ExcalidrawLinearElement);
|
||||||
expect(x1).toEqual(360.3176068760539);
|
expect(x1).toEqual(360.3176068760539);
|
||||||
expect(y1).toEqual(185.90654264413516);
|
expect(y1).toEqual(185.90654264413516);
|
||||||
expect(x2).toEqual(473.8171188951176);
|
expect(x2).toEqual(480.87005902729743);
|
||||||
expect(y2).toEqual(320.391865303557);
|
expect(y2).toEqual(320.4751269334226);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
|
|||||||
|
|
||||||
// x and y position of top left corner, x and y position of bottom right corner
|
// x and y position of top left corner, x and y position of bottom right corner
|
||||||
export type Bounds = readonly [number, number, number, number];
|
export type Bounds = readonly [number, number, number, number];
|
||||||
|
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||||
|
|
||||||
// If the element is created from right to left, the width is going to be negative
|
// If the element is created from right to left, the width is going to be negative
|
||||||
// This set of functions retrieves the absolute position of the 4 points.
|
// This set of functions retrieves the absolute position of the 4 points.
|
||||||
@@ -68,11 +69,102 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
|
|||||||
return shape.sets[0].ops;
|
return shape.sets[0].ops;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
|
||||||
|
const getBezierValueForT = (
|
||||||
|
t: number,
|
||||||
|
p0: number,
|
||||||
|
p1: number,
|
||||||
|
p2: number,
|
||||||
|
p3: number,
|
||||||
|
) => {
|
||||||
|
const oneMinusT = 1 - t;
|
||||||
|
return (
|
||||||
|
Math.pow(oneMinusT, 3) * p0 +
|
||||||
|
3 * Math.pow(oneMinusT, 2) * t * p1 +
|
||||||
|
3 * oneMinusT * Math.pow(t, 2) * p2 +
|
||||||
|
Math.pow(t, 3) * p3
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const solveQuadratic = (
|
||||||
|
p0: number,
|
||||||
|
p1: number,
|
||||||
|
p2: number,
|
||||||
|
p3: number,
|
||||||
|
): MaybeQuadraticSolution => {
|
||||||
|
const i = p1 - p0;
|
||||||
|
const j = p2 - p1;
|
||||||
|
const k = p3 - p2;
|
||||||
|
|
||||||
|
const a = 3 * i - 6 * j + 3 * k;
|
||||||
|
const b = 6 * j - 6 * i;
|
||||||
|
const c = 3 * i;
|
||||||
|
|
||||||
|
const sqrtPart = b * b - 4 * a * c;
|
||||||
|
const hasSolution = sqrtPart >= 0;
|
||||||
|
|
||||||
|
if (!hasSolution) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let s1 = null;
|
||||||
|
let s2 = null;
|
||||||
|
|
||||||
|
let t1 = Infinity;
|
||||||
|
let t2 = Infinity;
|
||||||
|
|
||||||
|
if (a === 0) {
|
||||||
|
t1 = t2 = -c / b;
|
||||||
|
} else {
|
||||||
|
t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
|
||||||
|
t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t1 >= 0 && t1 <= 1) {
|
||||||
|
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t2 >= 0 && t2 <= 1) {
|
||||||
|
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [s1, s2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCubicBezierCurveBound = (
|
||||||
|
p0: Point,
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
): Bounds => {
|
||||||
|
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||||
|
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||||
|
|
||||||
|
let minX = Math.min(p0[0], p3[0]);
|
||||||
|
let maxX = Math.max(p0[0], p3[0]);
|
||||||
|
|
||||||
|
if (solX) {
|
||||||
|
const xs = solX.filter((x) => x !== null) as number[];
|
||||||
|
minX = Math.min(minX, ...xs);
|
||||||
|
maxX = Math.max(maxX, ...xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let minY = Math.min(p0[1], p3[1]);
|
||||||
|
let maxY = Math.max(p0[1], p3[1]);
|
||||||
|
if (solY) {
|
||||||
|
const ys = solY.filter((y) => y !== null) as number[];
|
||||||
|
minY = Math.min(minY, ...ys);
|
||||||
|
maxY = Math.max(maxY, ...ys);
|
||||||
|
}
|
||||||
|
return [minX, minY, maxX, maxY];
|
||||||
|
};
|
||||||
|
|
||||||
const getMinMaxXYFromCurvePathOps = (
|
const getMinMaxXYFromCurvePathOps = (
|
||||||
ops: Op[],
|
ops: Op[],
|
||||||
transformXY?: (x: number, y: number) => [number, number],
|
transformXY?: (x: number, y: number) => [number, number],
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
let currentP: Point = [0, 0];
|
let currentP: Point = [0, 0];
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||||
(limits, { op, data }) => {
|
(limits, { op, data }) => {
|
||||||
// There are only four operation types:
|
// There are only four operation types:
|
||||||
@@ -83,38 +175,29 @@ const getMinMaxXYFromCurvePathOps = (
|
|||||||
// move operation does not draw anything; so, it always
|
// move operation does not draw anything; so, it always
|
||||||
// returns false
|
// returns false
|
||||||
} else if (op === "bcurveTo") {
|
} else if (op === "bcurveTo") {
|
||||||
// create points from bezier curve
|
const _p1 = [data[0], data[1]] as Point;
|
||||||
// bezier curve stores data as a flattened array of three positions
|
const _p2 = [data[2], data[3]] as Point;
|
||||||
// [x1, y1, x2, y2, x3, y3]
|
const _p3 = [data[4], data[5]] as Point;
|
||||||
const p1 = [data[0], data[1]] as Point;
|
|
||||||
const p2 = [data[2], data[3]] as Point;
|
|
||||||
const p3 = [data[4], data[5]] as Point;
|
|
||||||
|
|
||||||
const p0 = currentP;
|
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||||
currentP = p3;
|
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||||
|
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||||
|
|
||||||
const equation = (t: number, idx: number) =>
|
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
currentP = _p3;
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
|
|
||||||
let t = 0;
|
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||||
while (t <= 1.0) {
|
p0,
|
||||||
let x = equation(t, 0);
|
p1,
|
||||||
let y = equation(t, 1);
|
p2,
|
||||||
if (transformXY) {
|
p3,
|
||||||
[x, y] = transformXY(x, y);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
limits.minY = Math.min(limits.minY, y);
|
limits.minX = Math.min(limits.minX, minX);
|
||||||
limits.minX = Math.min(limits.minX, x);
|
limits.minY = Math.min(limits.minY, minY);
|
||||||
|
|
||||||
limits.maxX = Math.max(limits.maxX, x);
|
limits.maxX = Math.max(limits.maxX, maxX);
|
||||||
limits.maxY = Math.max(limits.maxY, y);
|
limits.maxY = Math.max(limits.maxY, maxY);
|
||||||
|
|
||||||
t += 0.1;
|
|
||||||
}
|
|
||||||
} else if (op === "lineTo") {
|
} else if (op === "lineTo") {
|
||||||
// TODO: Implement this
|
// TODO: Implement this
|
||||||
} else if (op === "qcurveTo") {
|
} else if (op === "qcurveTo") {
|
||||||
@@ -124,7 +207,6 @@ const getMinMaxXYFromCurvePathOps = (
|
|||||||
},
|
},
|
||||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||||
);
|
);
|
||||||
|
|
||||||
return [minX, minY, maxX, maxY];
|
return [minX, minY, maxX, maxY];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,34 +387,49 @@ export const getArrowheadPoints = (
|
|||||||
return [x2, y2, x3, y3, x4, y4];
|
return [x2, y2, x3, y3, x4, y4];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateLinearElementShape = (
|
||||||
|
element: ExcalidrawLinearElement,
|
||||||
|
): Drawable => {
|
||||||
|
const generator = rough.generator();
|
||||||
|
const options = generateRoughOptions(element);
|
||||||
|
|
||||||
|
const method = (() => {
|
||||||
|
if (element.strokeSharpness !== "sharp") {
|
||||||
|
return "curve";
|
||||||
|
}
|
||||||
|
if (options.fill) {
|
||||||
|
return "polygon";
|
||||||
|
}
|
||||||
|
return "linearPath";
|
||||||
|
})();
|
||||||
|
|
||||||
|
return generator[method](element.points as Mutable<Point>[], options);
|
||||||
|
};
|
||||||
|
|
||||||
const getLinearElementRotatedBounds = (
|
const getLinearElementRotatedBounds = (
|
||||||
element: ExcalidrawLinearElement,
|
element: ExcalidrawLinearElement,
|
||||||
cx: number,
|
cx: number,
|
||||||
cy: number,
|
cy: number,
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
if (element.points.length < 2) {
|
||||||
// XXX this is just a poor estimate and not very useful
|
const [pointX, pointY] = element.points[0];
|
||||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
const [x, y] = rotate(
|
||||||
(limits, [x, y]) => {
|
element.x + pointX,
|
||||||
[x, y] = rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
element.y + pointY,
|
||||||
limits.minY = Math.min(limits.minY, y);
|
cx,
|
||||||
limits.minX = Math.min(limits.minX, x);
|
cy,
|
||||||
limits.maxX = Math.max(limits.maxX, x);
|
element.angle,
|
||||||
limits.maxY = Math.max(limits.maxY, y);
|
|
||||||
return limits;
|
|
||||||
},
|
|
||||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
|
||||||
);
|
);
|
||||||
return [minX, minY, maxX, maxY];
|
return [x, y, x, y];
|
||||||
}
|
}
|
||||||
|
|
||||||
const shape = getShapeForElement(element)!;
|
|
||||||
|
|
||||||
// first element is always the curve
|
// first element is always the curve
|
||||||
const ops = getCurvePathOps(shape[0]);
|
const cachedShape = getShapeForElement(element)?.[0];
|
||||||
|
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||||
|
const ops = getCurvePathOps(shape);
|
||||||
const transformXY = (x: number, y: number) =>
|
const transformXY = (x: number, y: number) =>
|
||||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||||
|
|
||||||
return getMinMaxXYFromCurvePathOps(ops, transformXY);
|
return getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,6 +517,7 @@ export const getResizedElementAbsoluteCoords = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
nextWidth: number,
|
nextWidth: number,
|
||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
|
normalizePoints: boolean,
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||||
return [
|
return [
|
||||||
@@ -433,7 +531,8 @@ export const getResizedElementAbsoluteCoords = (
|
|||||||
const points = rescalePoints(
|
const points = rescalePoints(
|
||||||
0,
|
0,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
rescalePoints(1, nextHeight, element.points),
|
rescalePoints(1, nextHeight, element.points, normalizePoints),
|
||||||
|
normalizePoints,
|
||||||
);
|
);
|
||||||
|
|
||||||
let bounds: [number, number, number, number];
|
let bounds: [number, number, number, number];
|
||||||
@@ -454,6 +553,7 @@ export const getResizedElementAbsoluteCoords = (
|
|||||||
points as [number, number][],
|
points as [number, number][],
|
||||||
generateRoughOptions(element),
|
generateRoughOptions(element),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ops = getCurvePathOps(curve);
|
const ops = getCurvePathOps(curve);
|
||||||
bounds = getMinMaxXYFromCurvePathOps(ops);
|
bounds = getMinMaxXYFromCurvePathOps(ops);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { getShapeForElement } from "../renderer/renderElement";
|
|||||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isTransparent } from "../utils";
|
import { isTransparent } from "../utils";
|
||||||
|
import { shouldShowBoundingBox } from "./transformHandles";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
@@ -64,7 +65,10 @@ export const hitTest = (
|
|||||||
const threshold = 10 / appState.zoom.value;
|
const threshold = 10 / appState.zoom.value;
|
||||||
const point: Point = [x, y];
|
const point: Point = [x, y];
|
||||||
|
|
||||||
if (isElementSelected(appState, element)) {
|
if (
|
||||||
|
isElementSelected(appState, element) &&
|
||||||
|
shouldShowBoundingBox([element], appState)
|
||||||
|
) {
|
||||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,15 +105,26 @@ export const dragNewElement = (
|
|||||||
true */
|
true */
|
||||||
widthAspectRatio?: number | null,
|
widthAspectRatio?: number | null,
|
||||||
) => {
|
) => {
|
||||||
if (shouldMaintainAspectRatio) {
|
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||||
if (widthAspectRatio) {
|
if (widthAspectRatio) {
|
||||||
height = width / widthAspectRatio;
|
height = width / widthAspectRatio;
|
||||||
} else {
|
} else {
|
||||||
({ width, height } = getPerfectElementSize(
|
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||||
elementType,
|
// (originX, originY), we use ONLY width or height to control size increase.
|
||||||
width,
|
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||||
y < originY ? -height : height,
|
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||||
));
|
({ width, height } = getPerfectElementSize(
|
||||||
|
elementType,
|
||||||
|
height,
|
||||||
|
x < originX ? -width : width,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
({ width, height } = getPerfectElementSize(
|
||||||
|
elementType,
|
||||||
|
width,
|
||||||
|
y < originY ? -height : height,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if (height < 0) {
|
if (height < 0) {
|
||||||
height = -height;
|
height = -height;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
newElement,
|
newElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
updateTextElement,
|
updateTextElement,
|
||||||
|
refreshTextDimensions,
|
||||||
newLinearElement,
|
newLinearElement,
|
||||||
newImageElement,
|
newImageElement,
|
||||||
duplicateElement,
|
duplicateElement,
|
||||||
@@ -53,6 +54,7 @@ export { textWysiwyg } from "./textWysiwyg";
|
|||||||
export { redrawTextBoundingBox } from "./textElement";
|
export { redrawTextBoundingBox } from "./textElement";
|
||||||
export {
|
export {
|
||||||
getPerfectElementSize,
|
getPerfectElementSize,
|
||||||
|
getLockedLinearCursorAlignSize,
|
||||||
isInvisiblySmallElement,
|
isInvisiblySmallElement,
|
||||||
resizePerfectLineForNWHandler,
|
resizePerfectLineForNWHandler,
|
||||||
getNormalizedDimensions,
|
getNormalizedDimensions,
|
||||||
|
|||||||
@@ -5,8 +5,20 @@ import {
|
|||||||
PointBinding,
|
PointBinding,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
import {
|
||||||
import { getElementAbsoluteCoords } from ".";
|
distance2d,
|
||||||
|
rotate,
|
||||||
|
isPathALoop,
|
||||||
|
getGridPoint,
|
||||||
|
rotatePoint,
|
||||||
|
centerPoint,
|
||||||
|
getControlPointsForBezierCurve,
|
||||||
|
getBezierXY,
|
||||||
|
getBezierCurveLength,
|
||||||
|
mapIntervalToBezierT,
|
||||||
|
arePointsEqual,
|
||||||
|
} from "../math";
|
||||||
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import { getElementPointsCoords } from "./bounds";
|
import { getElementPointsCoords } from "./bounds";
|
||||||
import { Point, AppState } from "../types";
|
import { Point, AppState } from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@@ -20,26 +32,38 @@ import {
|
|||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { tupleToCoors } from "../utils";
|
import { tupleToCoors } from "../utils";
|
||||||
import { isBindingElement } from "./typeChecks";
|
import { isBindingElement } from "./typeChecks";
|
||||||
|
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||||
|
|
||||||
|
const editorMidPointsCache: {
|
||||||
|
version: number | null;
|
||||||
|
points: (Point | null)[];
|
||||||
|
zoom: number | null;
|
||||||
|
} = { version: null, points: [], zoom: null };
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
/** indices */
|
/** indices */
|
||||||
public selectedPointsIndices: readonly number[] | null;
|
public readonly selectedPointsIndices: readonly number[] | null;
|
||||||
|
|
||||||
public pointerDownState: Readonly<{
|
public readonly pointerDownState: Readonly<{
|
||||||
prevSelectedPointsIndices: readonly number[] | null;
|
prevSelectedPointsIndices: readonly number[] | null;
|
||||||
/** index */
|
/** index */
|
||||||
lastClickedPoint: number;
|
lastClickedPoint: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/** whether you're dragging a point */
|
/** whether you're dragging a point */
|
||||||
public isDragging: boolean;
|
public readonly isDragging: boolean;
|
||||||
public lastUncommittedPoint: Point | null;
|
public readonly lastUncommittedPoint: Point | null;
|
||||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
public readonly startBindingElement:
|
||||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
| ExcalidrawBindableElement
|
||||||
|
| null
|
||||||
|
| "keep";
|
||||||
|
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
|
public readonly hoverPointIndex: number;
|
||||||
|
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||||
|
|
||||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
@@ -58,14 +82,15 @@ export class LinearElementEditor {
|
|||||||
prevSelectedPointsIndices: null,
|
prevSelectedPointsIndices: null,
|
||||||
lastClickedPoint: -1,
|
lastClickedPoint: -1,
|
||||||
};
|
};
|
||||||
|
this.hoverPointIndex = -1;
|
||||||
|
this.segmentMidPointHoveredCoords = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// static methods
|
// static methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 20;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
@@ -132,22 +157,20 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
/** @returns whether point was dragged */
|
/** @returns whether point was dragged */
|
||||||
static handlePointDragging(
|
static handlePointDragging(
|
||||||
|
event: PointerEvent,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
setState: React.Component<any, AppState>["setState"],
|
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
maybeSuggestBinding: (
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
pointSceneCoords: { x: number; y: number }[],
|
pointSceneCoords: { x: number; y: number }[],
|
||||||
) => void,
|
) => void,
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!appState.editingLinearElement) {
|
if (!linearElementEditor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||||
const { selectedPointsIndices, elementId, isDragging } =
|
|
||||||
editingLinearElement;
|
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
@@ -155,54 +178,71 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// point that's being dragged (out of all selected points)
|
// point that's being dragged (out of all selected points)
|
||||||
const draggingPoint = element.points[
|
const draggingPoint = element.points[
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint
|
linearElementEditor.pointerDownState.lastClickedPoint
|
||||||
] as [number, number] | undefined;
|
] as [number, number] | undefined;
|
||||||
|
|
||||||
if (selectedPointsIndices && draggingPoint) {
|
if (selectedPointsIndices && draggingPoint) {
|
||||||
if (isDragging === false) {
|
if (
|
||||||
setState({
|
shouldRotateWithDiscreteAngle(event) &&
|
||||||
editingLinearElement: {
|
selectedPointsIndices.length === 1 &&
|
||||||
...editingLinearElement,
|
element.points.length > 1
|
||||||
isDragging: true,
|
) {
|
||||||
},
|
const selectedIndex = selectedPointsIndices[0];
|
||||||
});
|
const referencePoint =
|
||||||
}
|
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||||
|
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||||
element,
|
element,
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
referencePoint,
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
[scenePointerX, scenePointerY],
|
||||||
appState.gridSize,
|
appState.gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
LinearElementEditor.movePoints(element, [
|
||||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
{
|
||||||
|
index: selectedIndex,
|
||||||
LinearElementEditor.movePoints(
|
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||||
element,
|
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
|
||||||
const newPointPosition =
|
|
||||||
pointIndex ===
|
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint
|
|
||||||
? LinearElementEditor.createPointAt(
|
|
||||||
element,
|
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
|
||||||
appState.gridSize,
|
|
||||||
)
|
|
||||||
: ([
|
|
||||||
element.points[pointIndex][0] + deltaX,
|
|
||||||
element.points[pointIndex][1] + deltaY,
|
|
||||||
] as const);
|
|
||||||
return {
|
|
||||||
index: pointIndex,
|
|
||||||
point: newPointPosition,
|
|
||||||
isDragging:
|
isDragging:
|
||||||
|
selectedIndex ===
|
||||||
|
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||||
|
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||||
|
|
||||||
|
LinearElementEditor.movePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
|
const newPointPosition =
|
||||||
pointIndex ===
|
pointIndex ===
|
||||||
editingLinearElement.pointerDownState.lastClickedPoint,
|
linearElementEditor.pointerDownState.lastClickedPoint
|
||||||
};
|
? LinearElementEditor.createPointAt(
|
||||||
}),
|
element,
|
||||||
);
|
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||||
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
)
|
||||||
|
: ([
|
||||||
|
element.points[pointIndex][0] + deltaX,
|
||||||
|
element.points[pointIndex][1] + deltaY,
|
||||||
|
] as const);
|
||||||
|
return {
|
||||||
|
index: pointIndex,
|
||||||
|
point: newPointPosition,
|
||||||
|
isDragging:
|
||||||
|
pointIndex ===
|
||||||
|
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
if (isBindingElement(element, false)) {
|
if (isBindingElement(element, false)) {
|
||||||
@@ -256,10 +296,12 @@ export class LinearElementEditor {
|
|||||||
return editingLinearElement;
|
return editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindings: Partial<
|
const bindings: Mutable<
|
||||||
Pick<
|
Partial<
|
||||||
InstanceType<typeof LinearElementEditor>,
|
Pick<
|
||||||
"startBindingElement" | "endBindingElement"
|
InstanceType<typeof LinearElementEditor>,
|
||||||
|
"startBindingElement" | "endBindingElement"
|
||||||
|
>
|
||||||
>
|
>
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -327,34 +369,266 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getEditorMidPoints = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
|
): typeof editorMidPointsCache["points"] => {
|
||||||
|
// Since its not needed outside editor unless 2 pointer lines
|
||||||
|
if (!appState.editingLinearElement && element.points.length > 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
editorMidPointsCache.version === element.version &&
|
||||||
|
editorMidPointsCache.zoom === appState.zoom.value
|
||||||
|
) {
|
||||||
|
return editorMidPointsCache.points;
|
||||||
|
}
|
||||||
|
LinearElementEditor.updateEditorMidPointsCache(element, appState);
|
||||||
|
return editorMidPointsCache.points!;
|
||||||
|
};
|
||||||
|
|
||||||
|
static updateEditorMidPointsCache = (
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const midpoints: (Point | null)[] = [];
|
||||||
|
while (index < points.length - 1) {
|
||||||
|
if (
|
||||||
|
LinearElementEditor.isSegmentTooShort(
|
||||||
|
element,
|
||||||
|
element.points[index],
|
||||||
|
element.points[index + 1],
|
||||||
|
appState.zoom,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
midpoints.push(null);
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
|
element,
|
||||||
|
points[index],
|
||||||
|
points[index + 1],
|
||||||
|
index + 1,
|
||||||
|
);
|
||||||
|
midpoints.push(segmentMidPoint);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
editorMidPointsCache.points = midpoints;
|
||||||
|
editorMidPointsCache.version = element.version;
|
||||||
|
editorMidPointsCache.zoom = appState.zoom.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
static getSegmentMidpointHitCoords = (
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
|
scenePointer: { x: number; y: number },
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const { elementId } = linearElementEditor;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
if (!element) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||||
|
element,
|
||||||
|
appState.zoom,
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (clickedPointIndex >= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
|
if (points.length >= 3 && !appState.editingLinearElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold =
|
||||||
|
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||||
|
|
||||||
|
const existingSegmentMidpointHitCoords =
|
||||||
|
linearElementEditor.segmentMidPointHoveredCoords;
|
||||||
|
if (existingSegmentMidpointHitCoords) {
|
||||||
|
const distance = distance2d(
|
||||||
|
existingSegmentMidpointHitCoords[0],
|
||||||
|
existingSegmentMidpointHitCoords[1],
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (distance <= threshold) {
|
||||||
|
return existingSegmentMidpointHitCoords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let index = 0;
|
||||||
|
const midPoints: typeof editorMidPointsCache["points"] =
|
||||||
|
LinearElementEditor.getEditorMidPoints(element, appState);
|
||||||
|
while (index < midPoints.length) {
|
||||||
|
if (midPoints[index] !== null) {
|
||||||
|
const distance = distance2d(
|
||||||
|
midPoints[index]![0],
|
||||||
|
midPoints[index]![1],
|
||||||
|
scenePointer.x,
|
||||||
|
scenePointer.y,
|
||||||
|
);
|
||||||
|
if (distance <= threshold) {
|
||||||
|
return midPoints[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
static isSegmentTooShort(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startPoint: Point,
|
||||||
|
endPoint: Point,
|
||||||
|
zoom: AppState["zoom"],
|
||||||
|
) {
|
||||||
|
let distance = distance2d(
|
||||||
|
startPoint[0],
|
||||||
|
startPoint[1],
|
||||||
|
endPoint[0],
|
||||||
|
endPoint[1],
|
||||||
|
);
|
||||||
|
if (element.points.length > 2 && element.strokeSharpness === "round") {
|
||||||
|
distance = getBezierCurveLength(element, endPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSegmentMidPoint(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
startPoint: Point,
|
||||||
|
endPoint: Point,
|
||||||
|
endPointIndex: number,
|
||||||
|
) {
|
||||||
|
let segmentMidPoint = centerPoint(startPoint, endPoint);
|
||||||
|
if (element.points.length > 2 && element.strokeSharpness === "round") {
|
||||||
|
const controlPoints = getControlPointsForBezierCurve(
|
||||||
|
element,
|
||||||
|
element.points[endPointIndex],
|
||||||
|
);
|
||||||
|
if (controlPoints) {
|
||||||
|
const t = mapIntervalToBezierT(
|
||||||
|
element,
|
||||||
|
element.points[endPointIndex],
|
||||||
|
0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [tx, ty] = getBezierXY(
|
||||||
|
controlPoints[0],
|
||||||
|
controlPoints[1],
|
||||||
|
controlPoints[2],
|
||||||
|
controlPoints[3],
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
[tx, ty],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentMidPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSegmentMidPointIndex(
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
|
appState: AppState,
|
||||||
|
midPoint: Point,
|
||||||
|
) {
|
||||||
|
const element = LinearElementEditor.getElement(
|
||||||
|
linearElementEditor.elementId,
|
||||||
|
);
|
||||||
|
if (!element) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
|
||||||
|
let index = 0;
|
||||||
|
while (index < midPoints.length - 1) {
|
||||||
|
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
setState: React.Component<any, AppState>["setState"],
|
|
||||||
history: History,
|
history: History,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
|
linearElementEditor: LinearElementEditor,
|
||||||
): {
|
): {
|
||||||
didAddPoint: boolean;
|
didAddPoint: boolean;
|
||||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||||
|
linearElementEditor: LinearElementEditor | null;
|
||||||
|
isMidPoint: boolean;
|
||||||
} {
|
} {
|
||||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||||
didAddPoint: false,
|
didAddPoint: false,
|
||||||
hitElement: null,
|
hitElement: null,
|
||||||
|
linearElementEditor: null,
|
||||||
|
isMidPoint: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!appState.editingLinearElement) {
|
if (!linearElementEditor) {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { elementId } = appState.editingLinearElement;
|
const { elementId } = linearElementEditor;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
|
linearElementEditor,
|
||||||
|
scenePointer,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
if (segmentMidPoint) {
|
||||||
|
const index = LinearElementEditor.getSegmentMidPointIndex(
|
||||||
|
linearElementEditor,
|
||||||
|
appState,
|
||||||
|
segmentMidPoint,
|
||||||
|
);
|
||||||
|
const newMidPoint = LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
segmentMidPoint[0],
|
||||||
|
segmentMidPoint[1],
|
||||||
|
appState.gridSize,
|
||||||
|
);
|
||||||
|
const points = [
|
||||||
|
...element.points.slice(0, index),
|
||||||
|
newMidPoint,
|
||||||
|
...element.points.slice(index),
|
||||||
|
];
|
||||||
|
mutateElement(element, {
|
||||||
|
points,
|
||||||
|
});
|
||||||
|
|
||||||
if (event.altKey) {
|
ret.didAddPoint = true;
|
||||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
ret.isMidPoint = true;
|
||||||
|
ret.linearElementEditor = {
|
||||||
|
...linearElementEditor,
|
||||||
|
selectedPointsIndices: element.points[1],
|
||||||
|
pointerDownState: {
|
||||||
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
|
lastClickedPoint: -1,
|
||||||
|
},
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.altKey && appState.editingLinearElement) {
|
||||||
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
...element.points,
|
...element.points,
|
||||||
@@ -366,24 +640,23 @@ export class LinearElementEditor {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
setState({
|
ret.linearElementEditor = {
|
||||||
editingLinearElement: {
|
...linearElementEditor,
|
||||||
...appState.editingLinearElement,
|
pointerDownState: {
|
||||||
pointerDownState: {
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
prevSelectedPointsIndices:
|
lastClickedPoint: -1,
|
||||||
appState.editingLinearElement.selectedPointsIndices,
|
|
||||||
lastClickedPoint: -1,
|
|
||||||
},
|
|
||||||
selectedPointsIndices: [element.points.length - 1],
|
|
||||||
lastUncommittedPoint: null,
|
|
||||||
endBindingElement: getHoveredElementForBinding(
|
|
||||||
scenePointer,
|
|
||||||
Scene.getScene(element)!,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
endBindingElement: getHoveredElementForBinding(
|
||||||
|
scenePointer,
|
||||||
|
Scene.getScene(element)!,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
@@ -397,7 +670,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// if we clicked on a point, set the element as hitElement otherwise
|
// if we clicked on a point, set the element as hitElement otherwise
|
||||||
// it would get deselected if the point is outside the hitbox area
|
// it would get deselected if the point is outside the hitbox area
|
||||||
if (clickedPointIndex > -1) {
|
if (clickedPointIndex >= 0 || segmentMidPoint) {
|
||||||
ret.hitElement = element;
|
ret.hitElement = element;
|
||||||
} else {
|
} else {
|
||||||
// You might be wandering why we are storing the binding elements on
|
// You might be wandering why we are storing the binding elements on
|
||||||
@@ -405,8 +678,7 @@ export class LinearElementEditor {
|
|||||||
// from the end points of the `linearElement` - this is to allow disabling
|
// from the end points of the `linearElement` - this is to allow disabling
|
||||||
// binding (which needs to happen at the point the user finishes moving
|
// binding (which needs to happen at the point the user finishes moving
|
||||||
// the point).
|
// the point).
|
||||||
const { startBindingElement, endBindingElement } =
|
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||||
appState.editingLinearElement;
|
|
||||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||||
bindOrUnbindLinearElement(
|
bindOrUnbindLinearElement(
|
||||||
element,
|
element,
|
||||||
@@ -432,47 +704,54 @@ export class LinearElementEditor {
|
|||||||
const nextSelectedPointsIndices =
|
const nextSelectedPointsIndices =
|
||||||
clickedPointIndex > -1 || event.shiftKey
|
clickedPointIndex > -1 || event.shiftKey
|
||||||
? event.shiftKey ||
|
? event.shiftKey ||
|
||||||
appState.editingLinearElement.selectedPointsIndices?.includes(
|
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
|
||||||
clickedPointIndex,
|
|
||||||
)
|
|
||||||
? normalizeSelectedPoints([
|
? normalizeSelectedPoints([
|
||||||
...(appState.editingLinearElement.selectedPointsIndices || []),
|
...(linearElementEditor.selectedPointsIndices || []),
|
||||||
clickedPointIndex,
|
clickedPointIndex,
|
||||||
])
|
])
|
||||||
: [clickedPointIndex]
|
: [clickedPointIndex]
|
||||||
: null;
|
: null;
|
||||||
|
ret.linearElementEditor = {
|
||||||
setState({
|
...linearElementEditor,
|
||||||
editingLinearElement: {
|
pointerDownState: {
|
||||||
...appState.editingLinearElement,
|
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||||
pointerDownState: {
|
lastClickedPoint: clickedPointIndex,
|
||||||
prevSelectedPointsIndices:
|
|
||||||
appState.editingLinearElement.selectedPointsIndices,
|
|
||||||
lastClickedPoint: clickedPointIndex,
|
|
||||||
},
|
|
||||||
selectedPointsIndices: nextSelectedPointsIndices,
|
|
||||||
pointerOffset: targetPoint
|
|
||||||
? {
|
|
||||||
x: scenePointer.x - targetPoint[0],
|
|
||||||
y: scenePointer.y - targetPoint[1],
|
|
||||||
}
|
|
||||||
: { x: 0, y: 0 },
|
|
||||||
},
|
},
|
||||||
});
|
selectedPointsIndices: nextSelectedPointsIndices,
|
||||||
|
pointerOffset: targetPoint
|
||||||
|
? {
|
||||||
|
x: scenePointer.x - targetPoint[0],
|
||||||
|
y: scenePointer.y - targetPoint[1],
|
||||||
|
}
|
||||||
|
: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static arePointsEqual(point1: Point | null, point2: Point | null) {
|
||||||
|
if (!point1 && !point2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!point1 || !point2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return arePointsEqual(point1, point2);
|
||||||
|
}
|
||||||
|
|
||||||
static handlePointerMove(
|
static handlePointerMove(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
editingLinearElement: LinearElementEditor,
|
appState: AppState,
|
||||||
gridSize: number | null,
|
): LinearElementEditor | null {
|
||||||
): LinearElementEditor {
|
if (!appState.editingLinearElement) {
|
||||||
const { elementId, lastUncommittedPoint } = editingLinearElement;
|
return null;
|
||||||
|
}
|
||||||
|
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return editingLinearElement;
|
return appState.editingLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@@ -482,15 +761,36 @@ export class LinearElementEditor {
|
|||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||||
}
|
}
|
||||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
return {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
lastUncommittedPoint: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPoint = LinearElementEditor.createPointAt(
|
let newPoint: Point;
|
||||||
element,
|
|
||||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
const lastCommittedPoint = points[points.length - 2];
|
||||||
gridSize,
|
|
||||||
);
|
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||||
|
element,
|
||||||
|
lastCommittedPoint,
|
||||||
|
[scenePointerX, scenePointerY],
|
||||||
|
appState.gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
newPoint = [
|
||||||
|
width + lastCommittedPoint[0],
|
||||||
|
height + lastCommittedPoint[1],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
|
element,
|
||||||
|
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||||
|
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||||
|
appState.gridSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(element, [
|
||||||
@@ -500,11 +800,10 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -526,14 +825,14 @@ export class LinearElementEditor {
|
|||||||
/** scene coords */
|
/** scene coords */
|
||||||
static getPointsGlobalCoordinates(
|
static getPointsGlobalCoordinates(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
) {
|
): Point[] {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
return element.points.map((point) => {
|
return element.points.map((point) => {
|
||||||
let { x, y } = element;
|
let { x, y } = element;
|
||||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||||
return [x, y];
|
return [x, y] as const;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +850,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const point = element.points[index];
|
const point = element.points[index];
|
||||||
const { x, y } = element;
|
const { x, y } = element;
|
||||||
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
return point
|
||||||
|
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
||||||
|
: rotate(x, y, cx, cy, element.angle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static pointFromAbsoluteCoords(
|
static pointFromAbsoluteCoords(
|
||||||
@@ -577,7 +878,8 @@ export class LinearElementEditor {
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
) {
|
) {
|
||||||
const pointHandles = this.getPointsGlobalCoordinates(element);
|
const pointHandles =
|
||||||
|
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||||
let idx = pointHandles.length;
|
let idx = pointHandles.length;
|
||||||
// loop from right to left because points on the right are rendered over
|
// loop from right to left because points on the right are rendered over
|
||||||
// points on the left, thus should take precedence when clicking, if they
|
// points on the left, thus should take precedence when clicking, if they
|
||||||
@@ -587,7 +889,7 @@ export class LinearElementEditor {
|
|||||||
if (
|
if (
|
||||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||||
// +1px to account for outline stroke
|
// +1px to account for outline stroke
|
||||||
this.POINT_HANDLE_SIZE / 2 + 1
|
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||||
) {
|
) {
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
@@ -746,6 +1048,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
appState: AppState,
|
||||||
targetPoints: { point: Point }[],
|
targetPoints: { point: Point }[],
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
@@ -775,9 +1078,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (selectedOriginPoint) {
|
if (selectedOriginPoint) {
|
||||||
offsetX =
|
offsetX =
|
||||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||||
offsetY =
|
offsetY =
|
||||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoints = points.map((point, idx) => {
|
const nextPoints = points.map((point, idx) => {
|
||||||
@@ -840,6 +1143,33 @@ export class LinearElementEditor {
|
|||||||
y: element.y + rotated[1],
|
y: element.y + rotated[1],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static _getShiftLockedDelta(
|
||||||
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
referencePoint: Point,
|
||||||
|
scenePointer: Point,
|
||||||
|
gridSize: number | null,
|
||||||
|
) {
|
||||||
|
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
|
element,
|
||||||
|
referencePoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [gridX, gridY] = getGridPoint(
|
||||||
|
scenePointer[0],
|
||||||
|
scenePointer[1],
|
||||||
|
gridSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height } = getLockedLinearCursorAlignSize(
|
||||||
|
referencePointCoords[0],
|
||||||
|
referencePointCoords[1],
|
||||||
|
gridX,
|
||||||
|
gridY,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rotatePoint([width, height], [0, 0], -element.angle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeSelectedPoints = (
|
const normalizeSelectedPoints = (
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ import { AppState } from "../types";
|
|||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { adjustXYWithRotation } from "../math";
|
import { adjustXYWithRotation } from "../math";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
import {
|
||||||
|
getContainerDims,
|
||||||
|
getContainerElement,
|
||||||
|
measureText,
|
||||||
|
wrapText,
|
||||||
|
} from "./textElement";
|
||||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||||
|
|
||||||
type ElementConstructorOpts = MarkOptional<
|
type ElementConstructorOpts = MarkOptional<
|
||||||
@@ -164,7 +169,8 @@ const getAdjustedDimensions = (
|
|||||||
let maxWidth = null;
|
let maxWidth = null;
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
if (container) {
|
if (container) {
|
||||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
const containerDims = getContainerDims(container);
|
||||||
|
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
@@ -198,6 +204,7 @@ const getAdjustedDimensions = (
|
|||||||
element,
|
element,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const deltaX1 = (x1 - nextX1) / 2;
|
const deltaX1 = (x1 - nextX1) / 2;
|
||||||
const deltaY1 = (y1 - nextY1) / 2;
|
const deltaY1 = (y1 - nextY1) / 2;
|
||||||
@@ -223,15 +230,16 @@ const getAdjustedDimensions = (
|
|||||||
// make sure container dimensions are set properly when
|
// make sure container dimensions are set properly when
|
||||||
// text editor overflows beyond viewport dimensions
|
// text editor overflows beyond viewport dimensions
|
||||||
if (container) {
|
if (container) {
|
||||||
let height = container.height;
|
const containerDims = getContainerDims(container);
|
||||||
let width = container.width;
|
let height = containerDims.height;
|
||||||
|
let width = containerDims.width;
|
||||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
||||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||||
}
|
}
|
||||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
||||||
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
||||||
}
|
}
|
||||||
if (height !== container.height || width !== container.width) {
|
if (height !== containerDims.height || width !== containerDims.width) {
|
||||||
mutateElement(container, { height, width });
|
mutateElement(container, { height, width });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,8 +252,33 @@ const getAdjustedDimensions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const refreshTextDimensions = (
|
||||||
|
textElement: ExcalidrawTextElement,
|
||||||
|
text = textElement.text,
|
||||||
|
) => {
|
||||||
|
const container = getContainerElement(textElement);
|
||||||
|
if (container) {
|
||||||
|
// text = wrapText(text, getFontString(textElement), container.width);
|
||||||
|
text = wrapText(
|
||||||
|
text,
|
||||||
|
getFontString(textElement),
|
||||||
|
getMaxContainerWidth(container),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dimensions = getAdjustedDimensions(textElement, text);
|
||||||
|
return { text, ...dimensions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||||
|
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||||
|
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateTextElement = (
|
export const updateTextElement = (
|
||||||
element: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
{
|
{
|
||||||
text,
|
text,
|
||||||
isDeleted,
|
isDeleted,
|
||||||
@@ -256,16 +289,10 @@ export const updateTextElement = (
|
|||||||
originalText: string;
|
originalText: string;
|
||||||
},
|
},
|
||||||
): ExcalidrawTextElement => {
|
): ExcalidrawTextElement => {
|
||||||
const container = getContainerElement(element);
|
return newElementWith(textElement, {
|
||||||
if (container) {
|
|
||||||
text = wrapText(text, getFontString(element), container.width);
|
|
||||||
}
|
|
||||||
const dimensions = getAdjustedDimensions(element, text);
|
|
||||||
return newElementWith(element, {
|
|
||||||
text,
|
|
||||||
originalText,
|
originalText,
|
||||||
isDeleted: isDeleted ?? element.isDeleted,
|
isDeleted: isDeleted ?? textElement.isDeleted,
|
||||||
...dimensions,
|
...refreshTextDimensions(textElement, originalText),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,6 +334,9 @@ export const newLinearElement = (
|
|||||||
export const newImageElement = (
|
export const newImageElement = (
|
||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawImageElement["type"];
|
type: ExcalidrawImageElement["type"];
|
||||||
|
status?: ExcalidrawImageElement["status"];
|
||||||
|
fileId?: ExcalidrawImageElement["fileId"];
|
||||||
|
scale?: ExcalidrawImageElement["scale"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawImageElement> => {
|
): NonDeleted<ExcalidrawImageElement> => {
|
||||||
return {
|
return {
|
||||||
@@ -314,9 +344,9 @@ export const newImageElement = (
|
|||||||
// in the future we'll support changing stroke color for some SVG elements,
|
// in the future we'll support changing stroke color for some SVG elements,
|
||||||
// and `transparent` will likely mean "use original colors of the image"
|
// and `transparent` will likely mean "use original colors of the image"
|
||||||
strokeColor: "transparent",
|
strokeColor: "transparent",
|
||||||
status: "pending",
|
status: opts.status ?? "pending",
|
||||||
fileId: null,
|
fileId: opts.fileId ?? null,
|
||||||
scale: [1, 1],
|
scale: opts.scale ?? [1, 1],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
|
getCommonBoundingBox,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
@@ -137,8 +138,10 @@ export const transformElements = (
|
|||||||
transformHandleType === "se"
|
transformHandleType === "se"
|
||||||
) {
|
) {
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
|
pointerDownState,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
|
shouldResizeFromCenter,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@@ -261,13 +264,15 @@ const rescalePointsInElement = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
|
normalizePoints: boolean,
|
||||||
) =>
|
) =>
|
||||||
isLinearElement(element) || isFreeDrawElement(element)
|
isLinearElement(element) || isFreeDrawElement(element)
|
||||||
? {
|
? {
|
||||||
points: rescalePoints(
|
points: rescalePoints(
|
||||||
0,
|
0,
|
||||||
width,
|
width,
|
||||||
rescalePoints(1, height, element.points),
|
rescalePoints(1, height, element.points, normalizePoints),
|
||||||
|
normalizePoints,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
@@ -371,6 +376,7 @@ const resizeSingleTextElement = (
|
|||||||
element,
|
element,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const deltaX1 = (x1 - nextX1) / 2;
|
const deltaX1 = (x1 - nextX1) / 2;
|
||||||
const deltaY1 = (y1 - nextY1) / 2;
|
const deltaY1 = (y1 - nextY1) / 2;
|
||||||
@@ -412,6 +418,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
stateAtResizeStart.width,
|
stateAtResizeStart.width,
|
||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft: Point = [x1, y1];
|
const startTopLeft: Point = [x1, y1];
|
||||||
const startBottomRight: Point = [x2, y2];
|
const startBottomRight: Point = [x2, y2];
|
||||||
@@ -429,6 +436,7 @@ export const resizeSingleElement = (
|
|||||||
element,
|
element,
|
||||||
element.width,
|
element.width,
|
||||||
element.height,
|
element.height,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
const boundsCurrentWidth = esx2 - esx1;
|
||||||
@@ -522,6 +530,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
eleNewWidth,
|
eleNewWidth,
|
||||||
eleNewHeight,
|
eleNewHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||||
@@ -592,6 +601,7 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart,
|
stateAtResizeStart,
|
||||||
eleNewWidth,
|
eleNewWidth,
|
||||||
eleNewHeight,
|
eleNewHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||||
// So we need to readjust (x,y) to be where the first point should be
|
// So we need to readjust (x,y) to be where the first point should be
|
||||||
@@ -637,146 +647,152 @@ export const resizeSingleElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resizeMultipleElements = (
|
const resizeMultipleElements = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
pointerDownState: PointerDownState,
|
||||||
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||||
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
// map selected elements to the original elements. While it never should
|
||||||
let scale: number;
|
// happen that pointerDownState.originalElements won't contain the selected
|
||||||
let getNextXY: (
|
// elements during resize, this coupling isn't guaranteed, so to ensure
|
||||||
element: NonDeletedExcalidrawElement,
|
// type safety we need to transform only those elements we filter.
|
||||||
origCoords: readonly [number, number, number, number],
|
const targetElements = selectedElements.reduce(
|
||||||
finalCoords: readonly [number, number, number, number],
|
(
|
||||||
) => { x: number; y: number };
|
acc: {
|
||||||
switch (transformHandleType) {
|
/** element at resize start */
|
||||||
case "se":
|
orig: NonDeletedExcalidrawElement;
|
||||||
scale = Math.max(
|
/** latest element */
|
||||||
(pointerX - x1) / (x2 - x1),
|
latest: NonDeletedExcalidrawElement;
|
||||||
(pointerY - y1) / (y2 - y1),
|
}[],
|
||||||
);
|
element,
|
||||||
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
|
) => {
|
||||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
const origElement = pointerDownState.originalElements.get(element.id);
|
||||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
if (origElement) {
|
||||||
return { x, y };
|
acc.push({ orig: origElement, latest: element });
|
||||||
};
|
}
|
||||||
break;
|
return acc;
|
||||||
case "nw":
|
},
|
||||||
scale = Math.max(
|
[],
|
||||||
(x2 - pointerX) / (x2 - x1),
|
);
|
||||||
(y2 - pointerY) / (y2 - y1),
|
|
||||||
);
|
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||||
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
|
targetElements.map(({ orig }) => orig),
|
||||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
);
|
||||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
const direction = transformHandleType;
|
||||||
return { x, y };
|
|
||||||
};
|
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||||
break;
|
ne: [minX, maxY],
|
||||||
case "ne":
|
se: [minX, minY],
|
||||||
scale = Math.max(
|
sw: [maxX, minY],
|
||||||
(pointerX - x1) / (x2 - x1),
|
nw: [maxX, maxY],
|
||||||
(y2 - pointerY) / (y2 - y1),
|
};
|
||||||
);
|
|
||||||
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
|
// anchor point must be on the opposite side of the dragged selection handle
|
||||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
// or be the center of the selection if alt is pressed
|
||||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||||
return { x, y };
|
? [midX, midY]
|
||||||
};
|
: mapDirectionsToAnchors[direction];
|
||||||
break;
|
|
||||||
case "sw":
|
const mapDirectionsToPointerSides: Record<
|
||||||
scale = Math.max(
|
typeof direction,
|
||||||
(x2 - pointerX) / (x2 - x1),
|
[x: boolean, y: boolean]
|
||||||
(pointerY - y1) / (y2 - y1),
|
> = {
|
||||||
);
|
ne: [pointerX >= anchorX, pointerY <= anchorY],
|
||||||
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
|
se: [pointerX >= anchorX, pointerY >= anchorY],
|
||||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
sw: [pointerX <= anchorX, pointerY >= anchorY],
|
||||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
||||||
return { x, y };
|
};
|
||||||
};
|
|
||||||
break;
|
// pointer side relative to anchor
|
||||||
|
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
|
||||||
|
direction
|
||||||
|
].map((condition) => (condition ? 1 : -1));
|
||||||
|
|
||||||
|
// stop resizing if a pointer is on the other side of selection
|
||||||
|
if (pointerSideX < 0 && pointerSideY < 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (scale > 0) {
|
|
||||||
const updates = elements.reduce(
|
|
||||||
(prev, element) => {
|
|
||||||
if (!prev) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const width = element.width * scale;
|
|
||||||
const height = element.height * scale;
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
let font: { fontSize?: number; baseline?: number } = {};
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
const scale =
|
||||||
const nextFont = measureFontSizeFromWH(
|
Math.max(
|
||||||
boundTextElement,
|
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
||||||
width - BOUND_TEXT_PADDING * 2,
|
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||||
height - BOUND_TEXT_PADDING * 2,
|
) * (shouldResizeFromCenter ? 2 : 1);
|
||||||
);
|
|
||||||
|
|
||||||
if (nextFont === null) {
|
if (scale === 0) {
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
font = {
|
|
||||||
fontSize: nextFont.size,
|
|
||||||
baseline: nextFont.baseline,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTextElement(element)) {
|
targetElements.forEach((element) => {
|
||||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
const width = element.orig.width * scale;
|
||||||
if (nextFont === null) {
|
const height = element.orig.height * scale;
|
||||||
return null;
|
const x = anchorX + (element.orig.x - anchorX) * scale;
|
||||||
}
|
const y = anchorY + (element.orig.y - anchorY) * scale;
|
||||||
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
|
|
||||||
}
|
|
||||||
const origCoords = getElementAbsoluteCoords(element);
|
|
||||||
|
|
||||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
// readjust points for linear & free draw elements
|
||||||
|
const rescaledPoints = rescalePointsInElement(
|
||||||
updateBoundElements(element, {
|
element.orig,
|
||||||
newSize: { width, height },
|
width,
|
||||||
simultaneouslyUpdated: elements,
|
height,
|
||||||
});
|
false,
|
||||||
|
|
||||||
const finalCoords = getResizedElementAbsoluteCoords(
|
|
||||||
{
|
|
||||||
...element,
|
|
||||||
...rescaledPoints,
|
|
||||||
},
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { x, y } = getNextXY(element, origCoords, finalCoords);
|
|
||||||
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
|
|
||||||
},
|
|
||||||
[] as
|
|
||||||
| {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
points?: (readonly [number, number])[];
|
|
||||||
fontSize?: number;
|
|
||||||
baseline?: number;
|
|
||||||
}[]
|
|
||||||
| null,
|
|
||||||
);
|
);
|
||||||
if (updates) {
|
|
||||||
elements.forEach((element, index) => {
|
|
||||||
mutateElement(element, updates[index]);
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
const update: {
|
||||||
mutateElement(boundTextElement, {
|
width: number;
|
||||||
fontSize: updates[index].fontSize,
|
height: number;
|
||||||
baseline: updates[index].baseline,
|
x: number;
|
||||||
});
|
y: number;
|
||||||
handleBindTextResize(element, transformHandleType);
|
points?: Point[];
|
||||||
}
|
fontSize?: number;
|
||||||
});
|
baseline?: number;
|
||||||
|
} = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
...rescaledPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(element.latest);
|
||||||
|
|
||||||
|
if (boundTextElement || isTextElement(element.orig)) {
|
||||||
|
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
|
||||||
|
const textMeasurements = measureFontSizeFromWH(
|
||||||
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
|
width - optionalPadding,
|
||||||
|
height - optionalPadding,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!textMeasurements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTextElement(element.orig)) {
|
||||||
|
update.fontSize = textMeasurements.size;
|
||||||
|
update.baseline = textMeasurements.baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
boundTextUpdates = {
|
||||||
|
fontSize: textMeasurements.size,
|
||||||
|
baseline: textMeasurements.baseline,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
updateBoundElements(element.latest, { newSize: { width, height } });
|
||||||
|
|
||||||
|
mutateElement(element.latest, update);
|
||||||
|
|
||||||
|
if (boundTextElement && boundTextUpdates) {
|
||||||
|
mutateElement(boundTextElement, boundTextUpdates);
|
||||||
|
handleBindTextResize(element.latest, transformHandleType);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const rotateMultipleElements = (
|
const rotateMultipleElements = (
|
||||||
|
|||||||
@@ -1,49 +1,51 @@
|
|||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import * as constants from "../constants";
|
import * as constants from "../constants";
|
||||||
|
|
||||||
|
const EPSILON_DIGITS = 3;
|
||||||
|
|
||||||
describe("getPerfectElementSize", () => {
|
describe("getPerfectElementSize", () => {
|
||||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||||
expect(width).toEqual(149);
|
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(140);
|
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||||
expect(width).toEqual(200);
|
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(100);
|
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||||
expect(width).toEqual(120);
|
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(208);
|
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||||
expect(width).toEqual(135);
|
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(135);
|
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||||
expect(width).toEqual(0);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(0);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||||
expect(width).toEqual(120);
|
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
expect(height).toEqual(120);
|
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ export const getPerfectElementSize = (
|
|||||||
} else if (lockedAngle === Math.PI / 2) {
|
} else if (lockedAngle === Math.PI / 2) {
|
||||||
width = 0;
|
width = 0;
|
||||||
} else {
|
} else {
|
||||||
height =
|
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
|
||||||
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
|
|
||||||
height;
|
|
||||||
}
|
}
|
||||||
} else if (elementType !== "selection") {
|
} else if (elementType !== "selection") {
|
||||||
height = absWidth * Math.sign(height);
|
height = absWidth * Math.sign(height);
|
||||||
@@ -47,6 +45,46 @@ export const getPerfectElementSize = (
|
|||||||
return { width, height };
|
return { width, height };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLockedLinearCursorAlignSize = (
|
||||||
|
originX: number,
|
||||||
|
originY: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) => {
|
||||||
|
let width = x - originX;
|
||||||
|
let height = y - originY;
|
||||||
|
|
||||||
|
const lockedAngle =
|
||||||
|
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||||
|
SHIFT_LOCKING_ANGLE;
|
||||||
|
|
||||||
|
if (lockedAngle === 0) {
|
||||||
|
height = 0;
|
||||||
|
} else if (lockedAngle === Math.PI / 2) {
|
||||||
|
width = 0;
|
||||||
|
} else {
|
||||||
|
// locked angle line, y = mx + b => mx - y + b = 0
|
||||||
|
const a1 = Math.tan(lockedAngle);
|
||||||
|
const b1 = -1;
|
||||||
|
const c1 = originY - a1 * originX;
|
||||||
|
|
||||||
|
// line through cursor, perpendicular to locked angle line
|
||||||
|
const a2 = -1 / a1;
|
||||||
|
const b2 = -1;
|
||||||
|
const c2 = y - a2 * x;
|
||||||
|
|
||||||
|
// intersection of the two lines above
|
||||||
|
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
|
||||||
|
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
|
||||||
|
|
||||||
|
// delta
|
||||||
|
width = intersectX - originX;
|
||||||
|
height = intersectY - originY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
export const resizePerfectLineForNWHandler = (
|
export const resizePerfectLineForNWHandler = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
x: number,
|
x: number,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BOUND_TEXT_PADDING } from "../constants";
|
||||||
import { wrapText } from "./textElement";
|
import { wrapText } from "./textElement";
|
||||||
import { FontString } from "./types";
|
import { FontString } from "./types";
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ up`,
|
|||||||
},
|
},
|
||||||
].forEach((data) => {
|
].forEach((data) => {
|
||||||
it(`should ${data.desc}`, () => {
|
it(`should ${data.desc}`, () => {
|
||||||
const res = wrapText(text, font, data.width);
|
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||||
expect(res).toEqual(data.res);
|
expect(res).toEqual(data.res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -93,7 +94,7 @@ whats up`,
|
|||||||
},
|
},
|
||||||
].forEach((data) => {
|
].forEach((data) => {
|
||||||
it(`should respect new lines and ${data.desc}`, () => {
|
it(`should respect new lines and ${data.desc}`, () => {
|
||||||
const res = wrapText(text, font, data.width);
|
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||||
expect(res).toEqual(data.res);
|
expect(res).toEqual(data.res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -132,7 +133,7 @@ break it now`,
|
|||||||
},
|
},
|
||||||
].forEach((data) => {
|
].forEach((data) => {
|
||||||
it(`should ${data.desc}`, () => {
|
it(`should ${data.desc}`, () => {
|
||||||
const res = wrapText(text, font, data.width);
|
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||||
expect(res).toEqual(data.res);
|
expect(res).toEqual(data.res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,53 +7,67 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||||
import { MaybeTransformHandleType } from "./transformHandles";
|
import { MaybeTransformHandleType } from "./transformHandles";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
|
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
element: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
) => {
|
) => {
|
||||||
const maxWidth = container
|
let maxWidth = undefined;
|
||||||
? container.width - BOUND_TEXT_PADDING * 2
|
let text = textElement.text;
|
||||||
: undefined;
|
|
||||||
let text = element.text;
|
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
|
maxWidth = getMaxContainerWidth(container);
|
||||||
text = wrapText(
|
text = wrapText(
|
||||||
element.originalText,
|
textElement.originalText,
|
||||||
getFontString(element),
|
getFontString(textElement),
|
||||||
container.width,
|
getMaxContainerWidth(container),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const metrics = measureText(
|
const metrics = measureText(
|
||||||
element.originalText,
|
textElement.originalText,
|
||||||
getFontString(element),
|
getFontString(textElement),
|
||||||
maxWidth,
|
maxWidth,
|
||||||
);
|
);
|
||||||
let coordY = element.y;
|
let coordY = textElement.y;
|
||||||
let coordX = element.x;
|
let coordX = textElement.x;
|
||||||
// Resize container and vertically center align the text
|
// Resize container and vertically center align the text
|
||||||
if (container) {
|
if (container) {
|
||||||
let nextHeight = container.height;
|
const containerDims = getContainerDims(container);
|
||||||
coordX = container.x + BOUND_TEXT_PADDING;
|
let nextHeight = containerDims.height;
|
||||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||||
coordY = container.y + BOUND_TEXT_PADDING;
|
coordY = container.y + BOUND_TEXT_PADDING;
|
||||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY =
|
coordY =
|
||||||
container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
|
container.y +
|
||||||
|
containerDims.height -
|
||||||
|
metrics.height -
|
||||||
|
BOUND_TEXT_PADDING;
|
||||||
} else {
|
} else {
|
||||||
coordY = container.y + container.height / 2 - metrics.height / 2;
|
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||||
if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
|
if (metrics.height > getMaxContainerHeight(container)) {
|
||||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||||
|
coordX = container.x + BOUND_TEXT_PADDING;
|
||||||
|
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||||
|
coordX =
|
||||||
|
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
|
||||||
|
} else {
|
||||||
|
coordX = container.x + container.width / 2 - metrics.width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
}
|
}
|
||||||
mutateElement(element, {
|
|
||||||
|
mutateElement(textElement, {
|
||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
baseline: metrics.baseline,
|
baseline: metrics.baseline,
|
||||||
@@ -114,6 +128,7 @@ export const handleBindTextResize = (
|
|||||||
}
|
}
|
||||||
let text = textElement.text;
|
let text = textElement.text;
|
||||||
let nextHeight = textElement.height;
|
let nextHeight = textElement.height;
|
||||||
|
let nextWidth = textElement.width;
|
||||||
let containerHeight = element.height;
|
let containerHeight = element.height;
|
||||||
let nextBaseLine = textElement.baseline;
|
let nextBaseLine = textElement.baseline;
|
||||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||||
@@ -121,7 +136,7 @@ export const handleBindTextResize = (
|
|||||||
text = wrapText(
|
text = wrapText(
|
||||||
textElement.originalText,
|
textElement.originalText,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
element.width,
|
getMaxContainerWidth(element),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +146,7 @@ export const handleBindTextResize = (
|
|||||||
element.width,
|
element.width,
|
||||||
);
|
);
|
||||||
nextHeight = dimensions.height;
|
nextHeight = dimensions.height;
|
||||||
|
nextWidth = dimensions.width;
|
||||||
nextBaseLine = dimensions.baseline;
|
nextBaseLine = dimensions.baseline;
|
||||||
}
|
}
|
||||||
// increase height in case text element height exceeds
|
// increase height in case text element height exceeds
|
||||||
@@ -158,13 +174,17 @@ export const handleBindTextResize = (
|
|||||||
} else {
|
} else {
|
||||||
updatedY = element.y + element.height / 2 - nextHeight / 2;
|
updatedY = element.y + element.height / 2 - nextHeight / 2;
|
||||||
}
|
}
|
||||||
|
const updatedX =
|
||||||
|
textElement.textAlign === TEXT_ALIGN.LEFT
|
||||||
|
? element.x + BOUND_TEXT_PADDING
|
||||||
|
: textElement.textAlign === TEXT_ALIGN.RIGHT
|
||||||
|
? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
|
||||||
|
: element.x + element.width / 2 - nextWidth / 2;
|
||||||
mutateElement(textElement, {
|
mutateElement(textElement, {
|
||||||
text,
|
text,
|
||||||
// preserve padding and set width correctly
|
width: nextWidth,
|
||||||
width: element.width - BOUND_TEXT_PADDING * 2,
|
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: element.x + BOUND_TEXT_PADDING,
|
x: updatedX,
|
||||||
y: updatedY,
|
y: updatedY,
|
||||||
baseline: nextBaseLine,
|
baseline: nextBaseLine,
|
||||||
});
|
});
|
||||||
@@ -191,7 +211,6 @@ export const measureText = (
|
|||||||
container.style.minHeight = "1em";
|
container.style.minHeight = "1em";
|
||||||
if (maxWidth) {
|
if (maxWidth) {
|
||||||
const lineHeight = getApproxLineHeight(font);
|
const lineHeight = getApproxLineHeight(font);
|
||||||
container.style.width = `${String(maxWidth)}px`;
|
|
||||||
container.style.maxWidth = `${String(maxWidth)}px`;
|
container.style.maxWidth = `${String(maxWidth)}px`;
|
||||||
container.style.overflow = "hidden";
|
container.style.overflow = "hidden";
|
||||||
container.style.wordBreak = "break-word";
|
container.style.wordBreak = "break-word";
|
||||||
@@ -209,7 +228,8 @@ export const measureText = (
|
|||||||
container.appendChild(span);
|
container.appendChild(span);
|
||||||
// Baseline is important for positioning text on canvas
|
// Baseline is important for positioning text on canvas
|
||||||
const baseline = span.offsetTop + span.offsetHeight;
|
const baseline = span.offsetTop + span.offsetHeight;
|
||||||
const width = container.offsetWidth;
|
// Since span adds 1px extra width to the container
|
||||||
|
const width = container.offsetWidth + 1;
|
||||||
|
|
||||||
const height = container.offsetHeight;
|
const height = container.offsetHeight;
|
||||||
document.body.removeChild(container);
|
document.body.removeChild(container);
|
||||||
@@ -247,13 +267,7 @@ const getTextWidth = (text: string, font: FontString) => {
|
|||||||
return metrics.width;
|
return metrics.width;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const wrapText = (
|
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||||
text: string,
|
|
||||||
font: FontString,
|
|
||||||
containerWidth: number,
|
|
||||||
) => {
|
|
||||||
const maxWidth = containerWidth - BOUND_TEXT_PADDING * 2;
|
|
||||||
|
|
||||||
const lines: Array<string> = [];
|
const lines: Array<string> = [];
|
||||||
const originalLines = text.split("\n");
|
const originalLines = text.split("\n");
|
||||||
const spaceWidth = getTextWidth(" ", font);
|
const spaceWidth = getTextWidth(" ", font);
|
||||||
@@ -474,3 +488,7 @@ export const getContainerElement = (
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||||
|
return { width: element.width, height: element.height };
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import * as textElementUtils from "./textElement";
|
import * as textElementUtils from "./textElement";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
import { resize } from "../tests/utils";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
@@ -492,9 +493,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements[0].id).toBe(rectangle.id);
|
expect(h.elements[0].id).toBe(rectangle.id);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({}, () => {
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
|
|
||||||
@@ -695,9 +694,8 @@ describe("textWysiwyg", () => {
|
|||||||
// Edit and text by removing second line and it should
|
// Edit and text by removing second line and it should
|
||||||
// still vertically align correctly
|
// still vertically align correctly
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.withModifierKeys({}, () => {
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
|
||||||
});
|
|
||||||
editor = document.querySelector(
|
editor = document.querySelector(
|
||||||
".excalidraw-textEditorContainer > textarea",
|
".excalidraw-textEditorContainer > textarea",
|
||||||
) as HTMLTextAreaElement;
|
) as HTMLTextAreaElement;
|
||||||
@@ -734,9 +732,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements[0].id).toBe(rectangle.id);
|
expect(h.elements[0].id).toBe(rectangle.id);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({}, () => {
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
|
|
||||||
@@ -771,12 +767,11 @@ describe("textWysiwyg", () => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shouldn't bind to container if container has bound text", async () => {
|
it("shouldn't bind to container if container has bound text", async () => {
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({}, () => {
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
|
|
||||||
@@ -813,5 +808,73 @@ describe("textWysiwyg", () => {
|
|||||||
]);
|
]);
|
||||||
expect(text.containerId).toBe(null);
|
expect(text.containerId).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should respect text alignment when resizing", async () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
|
let editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
// should center align horizontally and vertically by default
|
||||||
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
109.5,
|
||||||
|
17,
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
|
mouse.select(rectangle);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
|
editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
editor.select();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle("Left"));
|
||||||
|
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
// should left align horizontally and bottom vertically after resize
|
||||||
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
15,
|
||||||
|
90,
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
|
mouse.select(rectangle);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
editor.select();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle("Right"));
|
||||||
|
fireEvent.click(screen.getByTitle("Align top"));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
// should right align horizontally and top vertically after resize
|
||||||
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
424,
|
||||||
|
-539,
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import {
|
import {
|
||||||
getApproxLineHeight,
|
getApproxLineHeight,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
wrapText,
|
wrapText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
} from "../actions/actionProperties";
|
} from "../actions/actionProperties";
|
||||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||||
import App from "../components/App";
|
import App from "../components/App";
|
||||||
|
import { getMaxContainerWidth } from "./newElement";
|
||||||
|
|
||||||
const normalizeText = (text: string) => {
|
const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
@@ -83,17 +85,17 @@ export const textWysiwyg = ({
|
|||||||
app: App;
|
app: App;
|
||||||
}) => {
|
}) => {
|
||||||
const textPropertiesUpdated = (
|
const textPropertiesUpdated = (
|
||||||
updatedElement: ExcalidrawTextElement,
|
updatedTextElement: ExcalidrawTextElement,
|
||||||
editable: HTMLTextAreaElement,
|
editable: HTMLTextAreaElement,
|
||||||
) => {
|
) => {
|
||||||
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||||||
if (
|
if (
|
||||||
getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !==
|
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||||||
currentFont
|
currentFont
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (`${updatedElement.fontSize}px` !== editable.style.fontSize) {
|
if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -102,74 +104,73 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
const updateWysiwygStyle = () => {
|
const updateWysiwygStyle = () => {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
const updatedElement =
|
const updatedTextElement =
|
||||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||||
if (!updatedElement) {
|
if (!updatedTextElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { textAlign, verticalAlign } = updatedElement;
|
const { textAlign, verticalAlign } = updatedTextElement;
|
||||||
|
|
||||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
const approxLineHeight = getApproxLineHeight(
|
||||||
if (updatedElement && isTextElement(updatedElement)) {
|
getFontString(updatedTextElement),
|
||||||
let coordX = updatedElement.x;
|
);
|
||||||
let coordY = updatedElement.y;
|
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||||
const container = getContainerElement(updatedElement);
|
const coordX = updatedTextElement.x;
|
||||||
let maxWidth = updatedElement.width;
|
let coordY = updatedTextElement.y;
|
||||||
|
const container = getContainerElement(updatedTextElement);
|
||||||
|
let maxWidth = updatedTextElement.width;
|
||||||
|
|
||||||
let maxHeight = updatedElement.height;
|
let maxHeight = updatedTextElement.height;
|
||||||
let width = updatedElement.width;
|
const width = updatedTextElement.width;
|
||||||
// Set to element height by default since that's
|
// Set to element height by default since that's
|
||||||
// what is going to be used for unbounded text
|
// what is going to be used for unbounded text
|
||||||
let height = updatedElement.height;
|
let height = updatedTextElement.height;
|
||||||
if (container && updatedElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
const propertiesUpdated = textPropertiesUpdated(
|
const propertiesUpdated = textPropertiesUpdated(
|
||||||
updatedElement,
|
updatedTextElement,
|
||||||
editable,
|
editable,
|
||||||
);
|
);
|
||||||
|
const containerDims = getContainerDims(container);
|
||||||
// using editor.style.height to get the accurate height of text editor
|
// using editor.style.height to get the accurate height of text editor
|
||||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||||
if (editorHeight > 0) {
|
if (editorHeight > 0) {
|
||||||
height = editorHeight;
|
height = editorHeight;
|
||||||
}
|
}
|
||||||
if (propertiesUpdated) {
|
if (propertiesUpdated) {
|
||||||
originalContainerHeight = container.height;
|
originalContainerHeight = containerDims.height;
|
||||||
|
|
||||||
// update height of the editor after properties updated
|
// update height of the editor after properties updated
|
||||||
height = updatedElement.height;
|
height = updatedTextElement.height;
|
||||||
}
|
}
|
||||||
if (!originalContainerHeight) {
|
if (!originalContainerHeight) {
|
||||||
originalContainerHeight = container.height;
|
originalContainerHeight = containerDims.height;
|
||||||
}
|
}
|
||||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||||
maxHeight = container.height - BOUND_TEXT_PADDING * 2;
|
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
|
||||||
width = maxWidth;
|
|
||||||
// The coordinates of text box set a distance of
|
|
||||||
// 5px to preserve padding
|
|
||||||
coordX = container.x + BOUND_TEXT_PADDING;
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
if (height > maxHeight) {
|
if (height > maxHeight) {
|
||||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
const diff = Math.min(height - maxHeight, approxLineHeight);
|
||||||
mutateElement(container, { height: container.height + diff });
|
mutateElement(container, { height: containerDims.height + diff });
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
// autoshrink container height until original container height
|
// autoshrink container height until original container height
|
||||||
// is reached when text is removed
|
// is reached when text is removed
|
||||||
container.height > originalContainerHeight &&
|
containerDims.height > originalContainerHeight &&
|
||||||
height < maxHeight
|
height < maxHeight
|
||||||
) {
|
) {
|
||||||
const diff = Math.min(maxHeight - height, approxLineHeight);
|
const diff = Math.min(maxHeight - height, approxLineHeight);
|
||||||
mutateElement(container, { height: container.height - diff });
|
mutateElement(container, { height: containerDims.height - diff });
|
||||||
}
|
}
|
||||||
// Start pushing text upward until a diff of 30px (padding)
|
// Start pushing text upward until a diff of 30px (padding)
|
||||||
// is reached
|
// is reached
|
||||||
else {
|
else {
|
||||||
// vertically center align the text
|
// vertically center align the text
|
||||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||||
coordY = container.y + container.height / 2 - height / 2;
|
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||||
}
|
}
|
||||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY =
|
coordY =
|
||||||
container.y + container.height - height - BOUND_TEXT_PADDING;
|
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +178,7 @@ export const textWysiwyg = ({
|
|||||||
const initialSelectionStart = editable.selectionStart;
|
const initialSelectionStart = editable.selectionStart;
|
||||||
const initialSelectionEnd = editable.selectionEnd;
|
const initialSelectionEnd = editable.selectionEnd;
|
||||||
const initialLength = editable.value.length;
|
const initialLength = editable.value.length;
|
||||||
editable.value = updatedElement.originalText;
|
editable.value = updatedTextElement.originalText;
|
||||||
|
|
||||||
// restore cursor position after value updated so it doesn't
|
// restore cursor position after value updated so it doesn't
|
||||||
// go to the end of text when container auto expanded
|
// go to the end of text when container auto expanded
|
||||||
@@ -192,10 +193,10 @@ export const textWysiwyg = ({
|
|||||||
editable.selectionEnd = editable.value.length - diff;
|
editable.selectionEnd = editable.value.length - diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = updatedElement.originalText.split("\n");
|
const lines = updatedTextElement.originalText.split("\n");
|
||||||
const lineHeight = updatedElement.containerId
|
const lineHeight = updatedTextElement.containerId
|
||||||
? approxLineHeight
|
? approxLineHeight
|
||||||
: updatedElement.height / lines.length;
|
: updatedTextElement.height / lines.length;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||||
}
|
}
|
||||||
@@ -203,12 +204,12 @@ export const textWysiwyg = ({
|
|||||||
// 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;
|
||||||
const angle = container ? container.angle : updatedElement.angle;
|
const angle = container ? container.angle : updatedTextElement.angle;
|
||||||
Object.assign(editable.style, {
|
Object.assign(editable.style, {
|
||||||
font: getFontString(updatedElement),
|
font: getFontString(updatedTextElement),
|
||||||
// must be defined *after* font ¯\_(ツ)_/¯
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
lineHeight: `${lineHeight}px`,
|
lineHeight: `${lineHeight}px`,
|
||||||
width: `${width}px`,
|
width: `${Math.min(width, maxWidth)}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
left: `${viewportX}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
@@ -222,18 +223,17 @@ export const textWysiwyg = ({
|
|||||||
),
|
),
|
||||||
textAlign,
|
textAlign,
|
||||||
verticalAlign,
|
verticalAlign,
|
||||||
color: updatedElement.strokeColor,
|
color: updatedTextElement.strokeColor,
|
||||||
opacity: updatedElement.opacity / 100,
|
opacity: updatedTextElement.opacity / 100,
|
||||||
filter: "var(--theme-filter)",
|
filter: "var(--theme-filter)",
|
||||||
maxWidth: `${maxWidth}px`,
|
|
||||||
maxHeight: `${editorMaxHeight}px`,
|
maxHeight: `${editorMaxHeight}px`,
|
||||||
});
|
});
|
||||||
// For some reason updating font attribute doesn't set font family
|
// For some reason updating font attribute doesn't set font family
|
||||||
// hence updating font family explicitly for test environment
|
// hence updating font family explicitly for test environment
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
editable.style.fontFamily = getFontFamilyString(updatedElement);
|
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||||
}
|
}
|
||||||
mutateElement(updatedElement, { x: coordX, y: coordY });
|
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,10 +276,10 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
editable.oninput = () => {
|
editable.oninput = () => {
|
||||||
const updatedElement = Scene.getScene(element)?.getElement(
|
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||||
id,
|
id,
|
||||||
) as ExcalidrawTextElement;
|
) as ExcalidrawTextElement;
|
||||||
const font = getFontString(updatedElement);
|
const font = getFontString(updatedTextElement);
|
||||||
// using scrollHeight here since we need to calculate
|
// using scrollHeight here since we need to calculate
|
||||||
// number of lines so cannot use editable.style.height
|
// number of lines so cannot use editable.style.height
|
||||||
// as that gets updated below
|
// as that gets updated below
|
||||||
@@ -297,13 +297,14 @@ export const textWysiwyg = ({
|
|||||||
// doubles the height as soon as user starts typing
|
// doubles the height as soon as user starts typing
|
||||||
if (isBoundToContainer(element) && lines > 1) {
|
if (isBoundToContainer(element) && lines > 1) {
|
||||||
let height = "auto";
|
let height = "auto";
|
||||||
|
editable.style.height = "0px";
|
||||||
|
let heightSet = false;
|
||||||
if (lines === 2) {
|
if (lines === 2) {
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
const actualLineCount = wrapText(
|
const actualLineCount = wrapText(
|
||||||
editable.value,
|
editable.value,
|
||||||
font,
|
font,
|
||||||
container!.width,
|
getMaxContainerWidth(container!),
|
||||||
).split("\n").length;
|
).split("\n").length;
|
||||||
// This is browser behaviour when setting height to "auto"
|
// This is browser behaviour when setting height to "auto"
|
||||||
// It sets the height needed for 2 lines even if actual
|
// It sets the height needed for 2 lines even if actual
|
||||||
@@ -312,10 +313,13 @@ export const textWysiwyg = ({
|
|||||||
// so single line aligns vertically when deleting
|
// so single line aligns vertically when deleting
|
||||||
if (actualLineCount === 1) {
|
if (actualLineCount === 1) {
|
||||||
height = `${editable.scrollHeight / 2}px`;
|
height = `${editable.scrollHeight / 2}px`;
|
||||||
|
editable.style.height = height;
|
||||||
|
heightSet = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editable.style.height = height;
|
if (!heightSet) {
|
||||||
editable.style.height = `${editable.scrollHeight}px`;
|
editable.style.height = `${editable.scrollHeight}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onChange(normalizeText(editable.value));
|
onChange(normalizeText(editable.value));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { ExcalidrawElement, PointerType } from "./types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
PointerType,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import { Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
|
import { isLinearElement } from "./typeChecks";
|
||||||
|
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
| "n"
|
| "n"
|
||||||
@@ -59,8 +65,6 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
|
|||||||
s: true,
|
s: true,
|
||||||
n: true,
|
n: true,
|
||||||
w: true,
|
w: true,
|
||||||
ne: true,
|
|
||||||
sw: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTransformHandle = (
|
const generateTransformHandle = (
|
||||||
@@ -82,6 +86,7 @@ export const getTransformHandlesFromCoords = (
|
|||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||||
|
margin = 4,
|
||||||
): TransformHandles => {
|
): TransformHandles => {
|
||||||
const size = transformHandleSizes[pointerType];
|
const size = transformHandleSizes[pointerType];
|
||||||
const handleWidth = size / zoom.value;
|
const handleWidth = size / zoom.value;
|
||||||
@@ -94,9 +99,7 @@ export const getTransformHandlesFromCoords = (
|
|||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
const dashedLineMargin = margin / zoom.value;
|
||||||
const dashedLineMargin = 4 / zoom.value;
|
|
||||||
|
|
||||||
const centeringOffset = (size - 8) / (2 * zoom.value);
|
const centeringOffset = (size - 8) / (2 * zoom.value);
|
||||||
|
|
||||||
const transformHandles: TransformHandles = {
|
const transformHandles: TransformHandles = {
|
||||||
@@ -230,11 +233,7 @@ export const getTransformHandles = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||||
if (
|
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||||
element.type === "arrow" ||
|
|
||||||
element.type === "line" ||
|
|
||||||
element.type === "freedraw"
|
|
||||||
) {
|
|
||||||
if (element.points.length === 2) {
|
if (element.points.length === 2) {
|
||||||
// only check the last point because starting point is always (0,0)
|
// only check the last point because starting point is always (0,0)
|
||||||
const [, p1] = element.points;
|
const [, p1] = element.points;
|
||||||
@@ -253,12 +252,33 @@ export const getTransformHandles = (
|
|||||||
} else if (isTextElement(element)) {
|
} else if (isTextElement(element)) {
|
||||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||||
}
|
}
|
||||||
|
const dashedLineMargin = isLinearElement(element)
|
||||||
|
? DEFAULT_SPACING * 3
|
||||||
|
: DEFAULT_SPACING;
|
||||||
return getTransformHandlesFromCoords(
|
return getTransformHandlesFromCoords(
|
||||||
getElementAbsoluteCoords(element),
|
getElementAbsoluteCoords(element),
|
||||||
element.angle,
|
element.angle,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
omitSides,
|
omitSides,
|
||||||
|
dashedLineMargin,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shouldShowBoundingBox = (
|
||||||
|
elements: NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (elements.length > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const element = elements[0];
|
||||||
|
if (!isLinearElement(element)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element.points.length > 2;
|
||||||
|
};
|
||||||
|
|||||||