Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 313097233d |
@@ -1,43 +0,0 @@
|
||||
{
|
||||
// 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,3 +25,4 @@ src/packages/excalidraw/types
|
||||
src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
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
|
||||
|
||||
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
||||
- You may need to sign in with GitHub and reload the page
|
||||
- You can start coding instantly, and even send PRs from there!
|
||||
|
||||
|
||||
+1
-2
@@ -41,8 +41,7 @@
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.2.0",
|
||||
"pica": "7.1.1",
|
||||
"perfect-freehand": "1.0.16",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
|
||||
+2
-21
@@ -52,25 +52,6 @@
|
||||
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>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@@ -117,7 +98,7 @@
|
||||
/>
|
||||
|
||||
<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>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
@@ -174,7 +155,7 @@
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const crowdinMap = {
|
||||
"fa-IR": "en-fa",
|
||||
"fi-FI": "en-fi",
|
||||
"fr-FR": "en-fr",
|
||||
"gl-ES": "en-gl",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
@@ -24,7 +23,6 @@ const crowdinMap = {
|
||||
"ja-JP": "en-ja",
|
||||
"kab-KAB": "en-kab",
|
||||
"ko-KR": "en-ko",
|
||||
"ku-TR": "en-ku",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
@@ -67,7 +65,6 @@ const flags = {
|
||||
"fa-IR": "🇮🇷",
|
||||
"fi-FI": "🇫🇮",
|
||||
"fr-FR": "🇫🇷",
|
||||
"gl-ES": "🇪🇸",
|
||||
"he-IL": "🇮🇱",
|
||||
"hi-IN": "🇮🇳",
|
||||
"hu-HU": "🇭🇺",
|
||||
@@ -77,7 +74,6 @@ const flags = {
|
||||
"kab-KAB": "🏳",
|
||||
"kk-KZ": "🇰🇿",
|
||||
"ko-KR": "🇰🇷",
|
||||
"ku-TR": "🏳",
|
||||
"lt-LT": "🇱🇹",
|
||||
"lv-LV": "🇱🇻",
|
||||
"my-MM": "🇲🇲",
|
||||
|
||||
+2
-3
@@ -1,12 +1,11 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// 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
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const actionCut = register({
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
|
||||
@@ -244,7 +244,7 @@ export const actionLoadScene = register({
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={load}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
@@ -181,11 +181,6 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: 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,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
|
||||
+22
-64
@@ -6,14 +6,10 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getElementPointsCoords,
|
||||
} from "../element/bounds";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -122,6 +118,13 @@ const flipElement = (
|
||||
const height = element.height;
|
||||
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
|
||||
mutateElement(element, {
|
||||
angle: normalizeAngle(0),
|
||||
@@ -129,6 +132,7 @@ const flipElement = (
|
||||
// Flip unrotated by pulling TransformHandle to opposite side
|
||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||
let usingNWHandle = true;
|
||||
let newNCoordsX = 0;
|
||||
let nHandle = transformHandles.nw;
|
||||
if (!nHandle) {
|
||||
// Use ne handle instead
|
||||
@@ -142,51 +146,30 @@ 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)) {
|
||||
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++) {
|
||||
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);
|
||||
} else {
|
||||
const elWidth = initialPointsCoords
|
||||
? initialPointsCoords[2] - initialPointsCoords[0]
|
||||
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
|
||||
|
||||
const startPoint = initialPointsCoords
|
||||
? [initialPointsCoords[0], initialPointsCoords[1]]
|
||||
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
|
||||
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
new Map().set(element.id, element),
|
||||
false,
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
true,
|
||||
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
|
||||
startPoint[1],
|
||||
false,
|
||||
newNCoordsX,
|
||||
nHandle[1],
|
||||
);
|
||||
// fix the size to account for handle sizes
|
||||
mutateElement(element, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by (360 degrees - original angle)
|
||||
@@ -203,34 +186,9 @@ const flipElement = (
|
||||
mutateElement(element, {
|
||||
x: originalX + finalOffsetX,
|
||||
y: originalY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
@@ -132,7 +132,7 @@ export const actionGroup = register({
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
@@ -189,9 +189,7 @@ export const actionUngroup = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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 { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
@@ -67,7 +67,7 @@ export const actionFullScreen = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
||||
@@ -3,42 +3,32 @@ import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
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 {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
selectedElementIds: elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
|
||||
@@ -17,19 +17,16 @@ export const actionToggleLock = register({
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
const lock = operation === "lock";
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: lock });
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||
},
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -85,4 +85,3 @@ export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
@@ -137,6 +137,7 @@ export class ActionManager {
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
this.actions[name] &&
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
@@ -146,7 +147,6 @@ export class ActionManager {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
|
||||
@@ -111,8 +111,7 @@ export type ActionName =
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor";
|
||||
| "toggleLock";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
+4
-6
@@ -57,7 +57,8 @@ export const getDefaultAppState = (): Omit<
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isSidebarDocked: false,
|
||||
isLibraryOpen: false,
|
||||
isLibraryMenuDocked: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@@ -66,7 +67,6 @@ export const getDefaultAppState = (): Omit<
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@@ -90,7 +90,6 @@ export const getDefaultAppState = (): Omit<
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -148,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isSidebarDocked: { browser: true, export: false, server: false },
|
||||
isLibraryOpen: { browser: true, export: false, server: false },
|
||||
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
@@ -159,7 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, 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 },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
@@ -182,7 +181,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
+12
-54
@@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
activeTool,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
@@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(appState.activeTool.type === "freedraw" ||
|
||||
{(activeTool === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(appState.activeTool.type) ||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(appState.activeTool.type) ||
|
||||
{(hasText(activeTool) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@@ -271,45 +271,3 @@ export const ZoomActions = ({
|
||||
</Stack.Row>
|
||||
</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>
|
||||
);
|
||||
|
||||
+224
-534
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,20 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
setAppState,
|
||||
actionManager,
|
||||
showThemeBtn,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
showThemeBtn: boolean;
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{actionManager.renderAction("toggleTheme")}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -343,8 +343,6 @@ const ColorInput = React.forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
ColorInput.displayName = "ColorInput";
|
||||
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
|
||||
@@ -85,7 +85,6 @@ export const Dialog = (props: DialogProps) => {
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useDevice().isMobile ? back : close}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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 "./HintViewer.scss";
|
||||
import { AppState, Device } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
@@ -17,19 +17,13 @@ interface HintViewerProps {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
device: Device;
|
||||
}
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,13 +111,11 @@ export const HintViewer = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
});
|
||||
if (!hint) {
|
||||
return null;
|
||||
|
||||
@@ -2,12 +2,10 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export const InitializeApp = (props: Props) => {
|
||||
@@ -23,5 +21,5 @@ export const InitializeApp = (props: Props) => {
|
||||
updateLang();
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
@import "open-color/open-color";
|
||||
@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 {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
|
||||
+221
-105
@@ -1,16 +1,16 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
@@ -26,7 +26,7 @@ import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@@ -39,10 +39,7 @@ import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./Footer";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -56,12 +53,12 @@ interface LayerUIProps {
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
showThemeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
@@ -74,18 +71,18 @@ const LayerUI = ({
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
elements,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
showThemeBtn,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
@@ -212,7 +209,12 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
@@ -242,11 +244,48 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Island>
|
||||
</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 shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
appState,
|
||||
@@ -300,7 +339,6 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
device={device}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
@@ -345,24 +383,100 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
return appState.openSidebar === "customSidebar" ? (
|
||||
renderCustomSidebar?.() || null
|
||||
) : appState.openSidebar === "library" ? (
|
||||
<LibraryMenu
|
||||
appState={appState}
|
||||
onInsertElements={onInsertElements}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
const renderBottomAppMenu = () => {
|
||||
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 && (
|
||||
<>
|
||||
<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 [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
|
||||
return (
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
@@ -390,80 +504,86 @@ 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
((appState.openSidebar === "library" &&
|
||||
appState.isSidebarDocked) ||
|
||||
hostSidebarCounters.docked) &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
const renderStats = () => {
|
||||
if (!appState.showStats) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
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()}
|
||||
</>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -482,12 +602,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||
prev.renderCustomStats === next.renderCustomStats &&
|
||||
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
|
||||
@@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? "library" : null });
|
||||
const nextState = event.target.checked;
|
||||
setAppState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (isOpen) {
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
@@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{
|
||||
);
|
||||
}
|
||||
}}
|
||||
checked={appState.openSidebar === "library"}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-ui__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
@@ -29,100 +23,16 @@
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.layer-ui__library {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.library-menu-items-container {
|
||||
height: 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 {
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
|
||||
+174
-177
@@ -6,31 +6,30 @@ import {
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
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 LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT, VERSIONS } from "../constants";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@@ -60,45 +59,112 @@ const useOnClickOutside = (
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="layer-ui__library">
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</div>
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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 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(
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
@@ -124,12 +190,60 @@ export const LibraryMenuContent = ({
|
||||
[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 (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
@@ -139,168 +253,51 @@ export const LibraryMenuContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
{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
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
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,7 +5,96 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,35 +1,226 @@
|
||||
import React, { useState } from "react";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { chunk } from "lodash";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { LibraryItem, LibraryItems } from "../types";
|
||||
import { arrayToMap, chunk } from "../utils";
|
||||
import {
|
||||
AppState,
|
||||
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 Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
const CELLS_PER_ROW = 4;
|
||||
import { SidebarLockButton } from "./SidebarLockButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
appState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => 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"][];
|
||||
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<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
@@ -106,6 +297,7 @@ const LibraryMenuItems = ({
|
||||
<Stack.Col key={params.key}>
|
||||
<LibraryUnit
|
||||
elements={params.item?.elements}
|
||||
files={files}
|
||||
isPending={!params.item?.id && !!params.item?.elements}
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
@@ -181,21 +373,56 @@ const LibraryMenuItems = ({
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
publishedItems.length || unpublishedItems.length
|
||||
? {
|
||||
flex: "1 1 0",
|
||||
overflowY: "auto",
|
||||
}
|
||||
: {
|
||||
marginBottom: "2rem",
|
||||
flex: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
const renderLibraryHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{device.canDeviceFitSidebar && (
|
||||
<>
|
||||
<div className="layer-ui__sidebar-lock-button">
|
||||
<SidebarLockButton
|
||||
checked={appState.isLibraryMenuDocked}
|
||||
onChange={() => {
|
||||
document
|
||||
.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
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
@@ -267,8 +494,8 @@ const LibraryMenuItems = ({
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0) && (
|
||||
(!device.isMobile &&
|
||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
@@ -290,6 +517,41 @@ const LibraryMenuItems = ({
|
||||
) : null}
|
||||
</>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
@@ -23,6 +23,7 @@ const PLUS_ICON = (
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
elements,
|
||||
files,
|
||||
isPending,
|
||||
onClick,
|
||||
selected,
|
||||
@@ -31,6 +32,7 @@ export const LibraryUnit = ({
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
files: BinaryFiles;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
@@ -54,7 +56,7 @@ export const LibraryUnit = ({
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null,
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
@@ -62,7 +64,7 @@ export const LibraryUnit = ({
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [elements]);
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
import clsx from "clsx";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
delay,
|
||||
theme,
|
||||
}) => {
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,11 +20,7 @@ export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("LoadingMessage", {
|
||||
"LoadingMessage--dark": theme === THEME.DARK,
|
||||
})}
|
||||
>
|
||||
<div className="LoadingMessage">
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@@ -18,8 +18,6 @@ import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -28,6 +26,7 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
@@ -37,19 +36,19 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderStats: () => JSX.Element | null;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
@@ -60,11 +59,10 @@ export const MobileMenu = ({
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderStats,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -109,15 +107,11 @@ export const MobileMenu = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={true}
|
||||
device={device}
|
||||
/>
|
||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
@@ -125,6 +119,7 @@ export const MobileMenu = ({
|
||||
const renderAppToolbar = () => {
|
||||
// Render eraser conditionally in mobile
|
||||
const showEraser =
|
||||
!appState.viewModeEnabled &&
|
||||
!appState.editingElement &&
|
||||
getSelectedElements(elements, appState).length === 0;
|
||||
|
||||
@@ -143,11 +138,11 @@ export const MobileMenu = ({
|
||||
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{showEraser
|
||||
? actionManager.renderAction("eraser")
|
||||
: actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{showEraser && actionManager.renderAction("eraser")}
|
||||
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
);
|
||||
@@ -175,25 +170,21 @@ export const MobileMenu = ({
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />}
|
||||
{
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{!appState.openMenu && appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
{renderStats()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@@ -230,6 +221,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
@@ -237,7 +229,7 @@ export const MobileMenu = ({
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
appState.openSidebar !== "library" && (
|
||||
!appState.isLibraryOpen && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
|
||||
@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
previewNode.replaceChildren();
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
previewNode.removeChild(svg);
|
||||
};
|
||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const Popover = ({
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
const element = popoverRef.current;
|
||||
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
|
||||
if (x + width - offsetLeft > viewportWidth) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
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,
|
||||
},
|
||||
);
|
||||
@@ -1,95 +0,0 @@
|
||||
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 };
|
||||
@@ -1,22 +0,0 @@
|
||||
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>({});
|
||||
@@ -0,0 +1,22 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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,6 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@@ -15,10 +16,13 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
if (device.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
|
||||
@@ -187,5 +187,3 @@ ToolButton.defaultProps = {
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
ToolButton.displayName = "ToolButton";
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
};
|
||||
+1
-17
@@ -99,9 +99,6 @@ export const MIME_TYPES = {
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
binary: "application/octet-stream",
|
||||
} as const;
|
||||
|
||||
@@ -152,7 +149,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
export: { saveFileToDisk: true },
|
||||
loadScene: true,
|
||||
saveToActiveFile: true,
|
||||
toggleTheme: null,
|
||||
theme: true,
|
||||
saveAsImage: true,
|
||||
},
|
||||
};
|
||||
@@ -183,9 +180,6 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
MIME_TYPES.webp,
|
||||
MIME_TYPES.bmp,
|
||||
MIME_TYPES.ico,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
@@ -207,18 +201,8 @@ export const VERTICAL_ALIGN = {
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const TEXT_ALIGN = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
@@ -32,8 +30,3 @@
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingMessage--dark {
|
||||
background-color: #121212;
|
||||
color: #ced4da;
|
||||
}
|
||||
|
||||
+2
-2
@@ -356,7 +356,7 @@ export const getFileHandle = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* attempts to detect if a buffer is a valid image by checking its leading bytes
|
||||
* attemps to detect if a buffer is a valid image by checking its leading bytes
|
||||
*/
|
||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
||||
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
||||
@@ -396,7 +396,7 @@ export const createFile = (
|
||||
});
|
||||
};
|
||||
|
||||
/** attempts to detect correct mimeType if none is set, or if an image
|
||||
/** attemps to detect correct mimeType if none is set, or if an image
|
||||
* has an incorrect extension.
|
||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||
export const normalizeFile = async (file: File) => {
|
||||
|
||||
+26
-44
@@ -148,7 +148,7 @@ class Library {
|
||||
defaultStatus?: "unpublished" | "published";
|
||||
}): Promise<LibraryItems> => {
|
||||
if (openLibraryMenu) {
|
||||
this.app.setState({ openSidebar: "library" });
|
||||
this.app.setState({ isLibraryOpen: true });
|
||||
}
|
||||
|
||||
return this.setLibrary(() => {
|
||||
@@ -365,56 +365,38 @@ export const useHandleLibrary = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const importLibraryFromURL = async ({
|
||||
const importLibraryFromURL = ({
|
||||
libraryUrl,
|
||||
idToken,
|
||||
}: {
|
||||
libraryUrl: string;
|
||||
idToken: string | null;
|
||||
}) => {
|
||||
const libraryPromise = 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);
|
||||
}
|
||||
});
|
||||
|
||||
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()}`);
|
||||
}
|
||||
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()}`);
|
||||
}
|
||||
|
||||
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 onHashChange = (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
+10
-83
@@ -9,7 +9,7 @@ import {
|
||||
LibraryItem,
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState, LegacyAppState } from "./types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
@@ -68,16 +67,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
/** metadata that may be present in elements during collaboration */
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
},
|
||||
T extends ExcalidrawElement,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: T,
|
||||
element: Required<T> & {
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
},
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
@@ -86,9 +82,7 @@ const restoreElementWithProperties = <
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
} = {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
@@ -121,14 +115,6 @@ const restoreElementWithProperties = <
|
||||
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 {
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
@@ -251,43 +237,6 @@ export const restoreElements = (
|
||||
}, [] 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 = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
@@ -295,30 +244,11 @@ export const restoreAppState = (
|
||||
appState = appState || {};
|
||||
const defaultAppState = getDefaultAppState();
|
||||
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 [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
][]) {
|
||||
// if AppState contains a legacy key, prefer that one and migrate its
|
||||
// value to the new one
|
||||
const suppliedValue = appState[key];
|
||||
|
||||
const localValue = localAppState ? localAppState[key] : undefined;
|
||||
(nextAppState as any)[key] =
|
||||
suppliedValue !== undefined
|
||||
@@ -355,12 +285,9 @@ export const restoreAppState = (
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
nextAppState.openSidebar === "library"
|
||||
? nextAppState.isSidebarDocked
|
||||
? "library"
|
||||
: null
|
||||
: nextAppState.openSidebar,
|
||||
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
||||
? nextAppState.isLibraryOpen
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
-21
@@ -17,32 +17,12 @@ export interface ExportedDataState {
|
||||
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 {
|
||||
type?: string;
|
||||
version?: number;
|
||||
source?: string;
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<
|
||||
Partial<
|
||||
AppState & {
|
||||
[T in keyof LegacyAppState]: LegacyAppState[T][0];
|
||||
}
|
||||
>
|
||||
> | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems_anyVersion;
|
||||
files?: BinaryFiles;
|
||||
|
||||
@@ -32,7 +32,6 @@ import { getElementAbsoluteCoords } from "./";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -49,15 +48,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
appState,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const linkVal = element.link || "";
|
||||
|
||||
const [inputVal, setInputVal] = useState(linkVal);
|
||||
|
||||
+29
-113
@@ -18,7 +18,6 @@ import { rescalePoints } from "../points";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
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
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
@@ -69,102 +68,11 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
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 = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
): [number, number, number, number] => {
|
||||
let currentP: Point = [0, 0];
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
// There are only four operation types:
|
||||
@@ -175,29 +83,38 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
const _p1 = [data[0], data[1]] as Point;
|
||||
const _p2 = [data[2], data[3]] as Point;
|
||||
const _p3 = [data[4], data[5]] as Point;
|
||||
// create points from bezier curve
|
||||
// bezier curve stores data as a flattened array of three positions
|
||||
// [x1, y1, x2, y2, x3, y3]
|
||||
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 p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||
const p0 = currentP;
|
||||
currentP = p3;
|
||||
|
||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||
currentP = _p3;
|
||||
const equation = (t: number, idx: number) =>
|
||||
Math.pow(1 - t, 3) * p3[idx] +
|
||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
);
|
||||
let t = 0;
|
||||
while (t <= 1.0) {
|
||||
let x = equation(t, 0);
|
||||
let y = equation(t, 1);
|
||||
if (transformXY) {
|
||||
[x, y] = transformXY(x, y);
|
||||
}
|
||||
|
||||
limits.minX = Math.min(limits.minX, minX);
|
||||
limits.minY = Math.min(limits.minY, minY);
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
|
||||
limits.maxX = Math.max(limits.maxX, maxX);
|
||||
limits.maxY = Math.max(limits.maxY, maxY);
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
|
||||
t += 0.1;
|
||||
}
|
||||
} else if (op === "lineTo") {
|
||||
// TODO: Implement this
|
||||
} else if (op === "qcurveTo") {
|
||||
@@ -207,6 +124,7 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
@@ -502,7 +420,6 @@ export const getResizedElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
normalizePoints: boolean,
|
||||
): [number, number, number, number] => {
|
||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||
return [
|
||||
@@ -516,8 +433,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
const points = rescalePoints(
|
||||
0,
|
||||
nextWidth,
|
||||
rescalePoints(1, nextHeight, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
rescalePoints(1, nextHeight, element.points),
|
||||
);
|
||||
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
@@ -35,7 +35,6 @@ import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -65,10 +64,7 @@ export const hitTest = (
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
const point: Point = [x, y];
|
||||
|
||||
if (
|
||||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
if (isElementSelected(appState, element)) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,26 +105,15 @@ export const dragNewElement = (
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||
// (originX, originY), we use ONLY width or height to control size increase.
|
||||
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||
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,
|
||||
));
|
||||
}
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
|
||||
@@ -53,7 +53,6 @@ export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
|
||||
+132
-462
@@ -5,20 +5,8 @@ import {
|
||||
PointBinding,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./types";
|
||||
import {
|
||||
distance2d,
|
||||
rotate,
|
||||
isPathALoop,
|
||||
getGridPoint,
|
||||
rotatePoint,
|
||||
centerPoint,
|
||||
getControlPointsForBezierCurve,
|
||||
getBezierXY,
|
||||
getBezierCurveLength,
|
||||
mapIntervalToBezierT,
|
||||
arePointsEqual,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
import { Point, AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -32,38 +20,26 @@ import {
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
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 {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
public elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
/** indices */
|
||||
public readonly selectedPointsIndices: readonly number[] | null;
|
||||
public selectedPointsIndices: readonly number[] | null;
|
||||
|
||||
public readonly pointerDownState: Readonly<{
|
||||
public pointerDownState: Readonly<{
|
||||
prevSelectedPointsIndices: readonly number[] | null;
|
||||
/** index */
|
||||
lastClickedPoint: number;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
public readonly isDragging: boolean;
|
||||
public readonly lastUncommittedPoint: Point | null;
|
||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public readonly startBindingElement:
|
||||
| ExcalidrawBindableElement
|
||||
| null
|
||||
| "keep";
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||
public isDragging: boolean;
|
||||
public lastUncommittedPoint: Point | null;
|
||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
this.elementId = element.id as string & {
|
||||
@@ -82,15 +58,14 @@ export class LinearElementEditor {
|
||||
prevSelectedPointsIndices: null,
|
||||
lastClickedPoint: -1,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
static POINT_HANDLE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
@@ -157,20 +132,22 @@ export class LinearElementEditor {
|
||||
|
||||
/** @returns whether point was dragged */
|
||||
static handlePointDragging(
|
||||
event: PointerEvent,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): boolean {
|
||||
if (!linearElementEditor) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId, isDragging } =
|
||||
editingLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
@@ -178,72 +155,55 @@ export class LinearElementEditor {
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
editingLinearElement.pointerDownState.lastClickedPoint
|
||||
] as [number, number] | undefined;
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (
|
||||
shouldRotateWithDiscreteAngle(event) &&
|
||||
selectedPointsIndices.length === 1 &&
|
||||
element.points.length > 1
|
||||
) {
|
||||
const selectedIndex = selectedPointsIndices[0];
|
||||
const referencePoint =
|
||||
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
referencePoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
appState.gridSize,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||
isDragging:
|
||||
selectedIndex ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||
if (isDragging === false) {
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
]);
|
||||
} 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 ===
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.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 ===
|
||||
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:
|
||||
pointIndex ===
|
||||
editingLinearElement.pointerDownState.lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element, false)) {
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
@@ -296,12 +256,10 @@ export class LinearElementEditor {
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
const bindings: Mutable<
|
||||
Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
>
|
||||
const bindings: Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
>
|
||||
> = {};
|
||||
|
||||
@@ -369,266 +327,34 @@ 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(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
isMidPoint: boolean;
|
||||
} {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
linearElementEditor: null,
|
||||
isMidPoint: false,
|
||||
};
|
||||
|
||||
if (!linearElementEditor) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const { elementId } = linearElementEditor;
|
||||
const { elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (!element) {
|
||||
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,
|
||||
});
|
||||
|
||||
ret.didAddPoint = true;
|
||||
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) {
|
||||
if (event.altKey) {
|
||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@@ -640,23 +366,24 @@ export class LinearElementEditor {
|
||||
),
|
||||
],
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
history.resumeRecording();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
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;
|
||||
return ret;
|
||||
}
|
||||
@@ -670,7 +397,7 @@ export class LinearElementEditor {
|
||||
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidPoint) {
|
||||
if (clickedPointIndex > -1) {
|
||||
ret.hitElement = element;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
@@ -678,7 +405,8 @@ export class LinearElementEditor {
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||
const { startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
@@ -704,54 +432,47 @@ export class LinearElementEditor {
|
||||
const nextSelectedPointsIndices =
|
||||
clickedPointIndex > -1 || event.shiftKey
|
||||
? event.shiftKey ||
|
||||
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
|
||||
appState.editingLinearElement.selectedPointsIndices?.includes(
|
||||
clickedPointIndex,
|
||||
)
|
||||
? normalizeSelectedPoints([
|
||||
...(linearElementEditor.selectedPointsIndices || []),
|
||||
...(appState.editingLinearElement.selectedPointsIndices || []),
|
||||
clickedPointIndex,
|
||||
])
|
||||
: [clickedPointIndex]
|
||||
: null;
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
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;
|
||||
}
|
||||
|
||||
static arePointsEqual(point1: Point | null, point2: Point | null) {
|
||||
if (!point1 && !point2) {
|
||||
return true;
|
||||
}
|
||||
if (!point1 || !point2) {
|
||||
return false;
|
||||
}
|
||||
return arePointsEqual(point1, point2);
|
||||
}
|
||||
|
||||
static handlePointerMove(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
appState: AppState,
|
||||
): LinearElementEditor | null {
|
||||
if (!appState.editingLinearElement) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
editingLinearElement: LinearElementEditor,
|
||||
gridSize: number | null,
|
||||
): LinearElementEditor {
|
||||
const { elementId, lastUncommittedPoint } = editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -761,36 +482,15 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||
}
|
||||
|
||||
let newPoint: Point;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const lastCommittedPoint = points[points.length - 2];
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
const newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
@@ -800,10 +500,11 @@ export class LinearElementEditor {
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
...editingLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@@ -825,14 +526,14 @@ export class LinearElementEditor {
|
||||
/** scene coords */
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
): Point[] {
|
||||
) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return element.points.map((point) => {
|
||||
let { x, y } = element;
|
||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return [x, y] as const;
|
||||
return [x, y];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -850,9 +551,7 @@ export class LinearElementEditor {
|
||||
|
||||
const point = element.points[index];
|
||||
const { x, y } = element;
|
||||
return point
|
||||
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
||||
: rotate(x, y, cx, cy, element.angle);
|
||||
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
@@ -878,8 +577,7 @@ export class LinearElementEditor {
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const pointHandles =
|
||||
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
const pointHandles = this.getPointsGlobalCoordinates(element);
|
||||
let idx = pointHandles.length;
|
||||
// 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
|
||||
@@ -889,7 +587,7 @@ export class LinearElementEditor {
|
||||
if (
|
||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
this.POINT_HANDLE_SIZE / 2 + 1
|
||||
) {
|
||||
return idx;
|
||||
}
|
||||
@@ -1048,7 +746,6 @@ export class LinearElementEditor {
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
targetPoints: { point: Point }[],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
@@ -1078,9 +775,9 @@ export class LinearElementEditor {
|
||||
|
||||
if (selectedOriginPoint) {
|
||||
offsetX =
|
||||
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
||||
offsetY =
|
||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
const nextPoints = points.map((point, idx) => {
|
||||
@@ -1143,33 +840,6 @@ export class LinearElementEditor {
|
||||
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 = (
|
||||
|
||||
+14
-37
@@ -21,12 +21,7 @@ import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
@@ -169,8 +164,7 @@ const getAdjustedDimensions = (
|
||||
let maxWidth = null;
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
const {
|
||||
width: nextWidth,
|
||||
@@ -204,7 +198,6 @@ const getAdjustedDimensions = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
@@ -230,16 +223,15 @@ const getAdjustedDimensions = (
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
let height = container.height;
|
||||
let width = container.width;
|
||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
||||
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (height !== containerDims.height || width !== containerDims.width) {
|
||||
if (height !== container.height || width !== container.width) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
@@ -252,16 +244,8 @@ const getAdjustedDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
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 = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
element: ExcalidrawTextElement,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
@@ -272,19 +256,15 @@ export const updateTextElement = (
|
||||
originalText: string;
|
||||
},
|
||||
): ExcalidrawTextElement => {
|
||||
const container = getContainerElement(textElement);
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
text = wrapText(
|
||||
originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
text = wrapText(text, getFontString(element), container.width);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
return newElementWith(textElement, {
|
||||
const dimensions = getAdjustedDimensions(element, text);
|
||||
return newElementWith(element, {
|
||||
text,
|
||||
originalText,
|
||||
isDeleted: isDeleted ?? textElement.isDeleted,
|
||||
isDeleted: isDeleted ?? element.isDeleted,
|
||||
...dimensions,
|
||||
});
|
||||
};
|
||||
@@ -327,9 +307,6 @@ export const newLinearElement = (
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
@@ -337,9 +314,9 @@ export const newImageElement = (
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
status: "pending",
|
||||
fileId: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+128
-139
@@ -18,7 +18,6 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
} from "./bounds";
|
||||
import {
|
||||
isFreeDrawElement,
|
||||
@@ -138,10 +137,8 @@ export const transformElements = (
|
||||
transformHandleType === "se"
|
||||
) {
|
||||
resizeMultipleElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -264,15 +261,13 @@ const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
normalizePoints: boolean,
|
||||
) =>
|
||||
isLinearElement(element) || isFreeDrawElement(element)
|
||||
? {
|
||||
points: rescalePoints(
|
||||
0,
|
||||
width,
|
||||
rescalePoints(1, height, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
rescalePoints(1, height, element.points),
|
||||
),
|
||||
}
|
||||
: {};
|
||||
@@ -376,7 +371,6 @@ const resizeSingleTextElement = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
@@ -418,7 +412,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
@@ -436,7 +429,6 @@ export const resizeSingleElement = (
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
true,
|
||||
);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
@@ -530,7 +522,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
@@ -601,7 +592,6 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
// 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
|
||||
@@ -647,147 +637,146 @@ export const resizeSingleElement = (
|
||||
};
|
||||
|
||||
const resizeMultipleElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
// map selected elements to the original elements. While it never should
|
||||
// happen that pointerDownState.originalElements won't contain the selected
|
||||
// elements during resize, this coupling isn't guaranteed, so to ensure
|
||||
// type safety we need to transform only those elements we filter.
|
||||
const targetElements = selectedElements.reduce(
|
||||
(
|
||||
acc: {
|
||||
/** element at resize start */
|
||||
orig: NonDeletedExcalidrawElement;
|
||||
/** latest element */
|
||||
latest: NonDeletedExcalidrawElement;
|
||||
}[],
|
||||
element,
|
||||
) => {
|
||||
const origElement = pointerDownState.originalElements.get(element.id);
|
||||
if (origElement) {
|
||||
acc.push({ orig: origElement, latest: element });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig),
|
||||
);
|
||||
const direction = transformHandleType;
|
||||
|
||||
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||
ne: [minX, maxY],
|
||||
se: [minX, minY],
|
||||
sw: [maxX, minY],
|
||||
nw: [maxX, maxY],
|
||||
};
|
||||
|
||||
// anchor point must be on the opposite side of the dragged selection handle
|
||||
// or be the center of the selection if alt is pressed
|
||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||
? [midX, midY]
|
||||
: mapDirectionsToAnchors[direction];
|
||||
|
||||
const mapDirectionsToPointerSides: Record<
|
||||
typeof direction,
|
||||
[x: boolean, y: boolean]
|
||||
> = {
|
||||
ne: [pointerX >= anchorX, pointerY <= anchorY],
|
||||
se: [pointerX >= anchorX, pointerY >= anchorY],
|
||||
sw: [pointerX <= anchorX, pointerY >= anchorY],
|
||||
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const scale =
|
||||
Math.max(
|
||||
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
||||
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||
) * (shouldResizeFromCenter ? 2 : 1);
|
||||
|
||||
if (scale === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElements.forEach((element) => {
|
||||
const width = element.orig.width * scale;
|
||||
const height = element.orig.height * scale;
|
||||
const x = anchorX + (element.orig.x - anchorX) * scale;
|
||||
const y = anchorY + (element.orig.y - anchorY) * scale;
|
||||
|
||||
// readjust points for linear & free draw elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
element.orig,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
);
|
||||
|
||||
const update: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
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,
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
let scale: number;
|
||||
let getNextXY: (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
origCoords: readonly [number, number, number, number],
|
||||
finalCoords: readonly [number, number, number, number],
|
||||
) => { x: number; y: number };
|
||||
switch (transformHandleType) {
|
||||
case "se":
|
||||
scale = Math.max(
|
||||
(pointerX - x1) / (x2 - x1),
|
||||
(pointerY - y1) / (y2 - y1),
|
||||
);
|
||||
if (textMeasurements) {
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
|
||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "nw":
|
||||
scale = Math.max(
|
||||
(x2 - pointerX) / (x2 - x1),
|
||||
(y2 - pointerY) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
|
||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "ne":
|
||||
scale = Math.max(
|
||||
(pointerX - x1) / (x2 - x1),
|
||||
(y2 - pointerY) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
|
||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "sw":
|
||||
scale = Math.max(
|
||||
(x2 - pointerX) / (x2 - x1),
|
||||
(pointerY - y1) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
|
||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
width - BOUND_TEXT_PADDING * 2,
|
||||
height - BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
}
|
||||
font = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutateElement(element.latest, update);
|
||||
if (isTextElement(element)) {
|
||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
}
|
||||
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
|
||||
}
|
||||
const origCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
if (boundTextElement && boundTextUpdates) {
|
||||
mutateElement(boundTextElement, boundTextUpdates);
|
||||
handleBindTextResize(element.latest, transformHandleType);
|
||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width, height },
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
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) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: updates[index].fontSize,
|
||||
baseline: updates[index].baseline,
|
||||
});
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(149);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
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);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(140);
|
||||
});
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(200);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
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);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(100);
|
||||
});
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(208);
|
||||
});
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(width).toEqual(135);
|
||||
expect(height).toEqual(135);
|
||||
});
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(0);
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(120);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,9 @@ export const getPerfectElementSize = (
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} else {
|
||||
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
|
||||
height =
|
||||
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
|
||||
height;
|
||||
}
|
||||
} else if (elementType !== "selection") {
|
||||
height = absWidth * Math.sign(height);
|
||||
@@ -45,46 +47,6 @@ export const getPerfectElementSize = (
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { wrapText } from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
@@ -46,7 +45,7 @@ up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +93,7 @@ whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
@@ -133,7 +132,7 @@ break it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
|
||||
+35
-53
@@ -7,67 +7,53 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
element: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
let text = textElement.text;
|
||||
const maxWidth = container
|
||||
? container.width - BOUND_TEXT_PADDING * 2
|
||||
: undefined;
|
||||
let text = element.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
container.width,
|
||||
);
|
||||
}
|
||||
const metrics = measureText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
let coordY = textElement.y;
|
||||
let coordX = textElement.x;
|
||||
let coordY = element.y;
|
||||
let coordX = element.x;
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
let nextHeight = container.height;
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
BOUND_TEXT_PADDING;
|
||||
container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
coordY = container.y + container.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 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(textElement, {
|
||||
mutateElement(element, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
@@ -128,7 +114,6 @@ export const handleBindTextResize = (
|
||||
}
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
let containerHeight = element.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
@@ -136,7 +121,7 @@ export const handleBindTextResize = (
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(element),
|
||||
element.width,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +131,6 @@ export const handleBindTextResize = (
|
||||
element.width,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
@@ -174,17 +158,13 @@ export const handleBindTextResize = (
|
||||
} else {
|
||||
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, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
// preserve padding and set width correctly
|
||||
width: element.width - BOUND_TEXT_PADDING * 2,
|
||||
height: nextHeight,
|
||||
x: updatedX,
|
||||
x: element.x + BOUND_TEXT_PADDING,
|
||||
y: updatedY,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
@@ -211,6 +191,7 @@ export const measureText = (
|
||||
container.style.minHeight = "1em";
|
||||
if (maxWidth) {
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
container.style.width = `${String(maxWidth)}px`;
|
||||
container.style.maxWidth = `${String(maxWidth)}px`;
|
||||
container.style.overflow = "hidden";
|
||||
container.style.wordBreak = "break-word";
|
||||
@@ -228,8 +209,7 @@ export const measureText = (
|
||||
container.appendChild(span);
|
||||
// Baseline is important for positioning text on canvas
|
||||
const baseline = span.offsetTop + span.offsetHeight;
|
||||
// Since span adds 1px extra width to the container
|
||||
const width = container.offsetWidth + 1;
|
||||
const width = container.offsetWidth;
|
||||
|
||||
const height = container.offsetHeight;
|
||||
document.body.removeChild(container);
|
||||
@@ -267,7 +247,13 @@ const getTextWidth = (text: string, font: FontString) => {
|
||||
return metrics.width;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
containerWidth: number,
|
||||
) => {
|
||||
const maxWidth = containerWidth - BOUND_TEXT_PADDING * 2;
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getTextWidth(" ", font);
|
||||
@@ -488,7 +474,3 @@ export const getContainerElement = (
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
@@ -493,7 +492,9 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@@ -694,8 +695,9 @@ describe("textWysiwyg", () => {
|
||||
// Edit and text by removing second line and it should
|
||||
// still vertically align correctly
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
@@ -732,7 +734,9 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@@ -767,11 +771,12 @@ describe("textWysiwyg", () => {
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't bind to container if container has bound text", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@@ -808,73 +813,5 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
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,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+49
-53
@@ -18,7 +18,6 @@ import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
|
||||
const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -85,17 +83,17 @@ export const textWysiwyg = ({
|
||||
app: App;
|
||||
}) => {
|
||||
const textPropertiesUpdated = (
|
||||
updatedTextElement: ExcalidrawTextElement,
|
||||
updatedElement: ExcalidrawTextElement,
|
||||
editable: HTMLTextAreaElement,
|
||||
) => {
|
||||
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||||
if (
|
||||
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||||
getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !==
|
||||
currentFont
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
|
||||
if (`${updatedElement.fontSize}px` !== editable.style.fontSize) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -104,73 +102,74 @@ export const textWysiwyg = ({
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedTextElement =
|
||||
const updatedElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
if (!updatedTextElement) {
|
||||
if (!updatedElement) {
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const { textAlign, verticalAlign } = updatedElement;
|
||||
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
const coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
const container = getContainerElement(updatedTextElement);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
||||
if (updatedElement && isTextElement(updatedElement)) {
|
||||
let coordX = updatedElement.x;
|
||||
let coordY = updatedElement.y;
|
||||
const container = getContainerElement(updatedElement);
|
||||
let maxWidth = updatedElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
const width = updatedTextElement.width;
|
||||
let maxHeight = updatedElement.height;
|
||||
let width = updatedElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedTextElement.height;
|
||||
if (container && updatedTextElement.containerId) {
|
||||
let height = updatedElement.height;
|
||||
if (container && updatedElement.containerId) {
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
updatedElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
height = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
originalContainerHeight = containerDims.height;
|
||||
originalContainerHeight = container.height;
|
||||
|
||||
// update height of the editor after properties updated
|
||||
height = updatedTextElement.height;
|
||||
height = updatedElement.height;
|
||||
}
|
||||
if (!originalContainerHeight) {
|
||||
originalContainerHeight = containerDims.height;
|
||||
originalContainerHeight = container.height;
|
||||
}
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
||||
maxHeight = container.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
|
||||
if (height > maxHeight) {
|
||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
mutateElement(container, { height: container.height + diff });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
containerDims.height > originalContainerHeight &&
|
||||
container.height > originalContainerHeight &&
|
||||
height < maxHeight
|
||||
) {
|
||||
const diff = Math.min(maxHeight - height, approxLineHeight);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
mutateElement(container, { height: container.height - diff });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||
coordY = container.y + container.height / 2 - height / 2;
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
|
||||
container.y + container.height - height - BOUND_TEXT_PADDING;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +177,7 @@ export const textWysiwyg = ({
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
editable.value = updatedTextElement.originalText;
|
||||
editable.value = updatedElement.originalText;
|
||||
|
||||
// restore cursor position after value updated so it doesn't
|
||||
// go to the end of text when container auto expanded
|
||||
@@ -193,10 +192,10 @@ export const textWysiwyg = ({
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
const lines = updatedElement.originalText.split("\n");
|
||||
const lineHeight = updatedElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
: updatedElement.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
}
|
||||
@@ -204,12 +203,12 @@ export const textWysiwyg = ({
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
const angle = container ? container.angle : updatedTextElement.angle;
|
||||
const angle = container ? container.angle : updatedElement.angle;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
font: getFontString(updatedElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${Math.min(width, maxWidth)}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
@@ -223,17 +222,18 @@ export const textWysiwyg = ({
|
||||
),
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
opacity: updatedTextElement.opacity / 100,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
maxWidth: `${maxWidth}px`,
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
if (isTestEnv()) {
|
||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||
editable.style.fontFamily = getFontFamilyString(updatedElement);
|
||||
}
|
||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||
mutateElement(updatedElement, { x: coordX, y: coordY });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -276,10 +276,10 @@ export const textWysiwyg = ({
|
||||
|
||||
if (onChange) {
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
const updatedElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
const font = getFontString(updatedElement);
|
||||
// using scrollHeight here since we need to calculate
|
||||
// number of lines so cannot use editable.style.height
|
||||
// as that gets updated below
|
||||
@@ -297,14 +297,13 @@ export const textWysiwyg = ({
|
||||
// doubles the height as soon as user starts typing
|
||||
if (isBoundToContainer(element) && lines > 1) {
|
||||
let height = "auto";
|
||||
editable.style.height = "0px";
|
||||
let heightSet = false;
|
||||
|
||||
if (lines === 2) {
|
||||
const container = getContainerElement(element);
|
||||
const actualLineCount = wrapText(
|
||||
editable.value,
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
container!.width,
|
||||
).split("\n").length;
|
||||
// This is browser behaviour when setting height to "auto"
|
||||
// It sets the height needed for 2 lines even if actual
|
||||
@@ -313,13 +312,10 @@ export const textWysiwyg = ({
|
||||
// so single line aligns vertically when deleting
|
||||
if (actualLineCount === 1) {
|
||||
height = `${editable.scrollHeight / 2}px`;
|
||||
editable.style.height = height;
|
||||
heightSet = true;
|
||||
}
|
||||
}
|
||||
if (!heightSet) {
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
editable.style.height = height;
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement, PointerType } from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
@@ -65,6 +59,8 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
ne: true,
|
||||
sw: true,
|
||||
};
|
||||
|
||||
const generateTransformHandle = (
|
||||
@@ -86,7 +82,6 @@ export const getTransformHandlesFromCoords = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
margin = 4,
|
||||
): TransformHandles => {
|
||||
const size = transformHandleSizes[pointerType];
|
||||
const handleWidth = size / zoom.value;
|
||||
@@ -99,7 +94,9 @@ export const getTransformHandlesFromCoords = (
|
||||
const height = y2 - y1;
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
|
||||
const dashedLineMargin = 4 / zoom.value;
|
||||
|
||||
const centeringOffset = (size - 8) / (2 * zoom.value);
|
||||
|
||||
const transformHandles: TransformHandles = {
|
||||
@@ -233,7 +230,11 @@ export const getTransformHandles = (
|
||||
}
|
||||
|
||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "freedraw"
|
||||
) {
|
||||
if (element.points.length === 2) {
|
||||
// only check the last point because starting point is always (0,0)
|
||||
const [, p1] = element.points;
|
||||
@@ -252,33 +253,12 @@ export const getTransformHandles = (
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING * 3
|
||||
: DEFAULT_SPACING;
|
||||
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element),
|
||||
element.angle,
|
||||
zoom,
|
||||
pointerType,
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Point } from "../types";
|
||||
import { FONT_FAMILY, TEXT_ALIGN, THEME, VERTICAL_ALIGN } from "../constants";
|
||||
import { FONT_FAMILY, THEME, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
@@ -11,7 +11,7 @@ export type GroupId = string;
|
||||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
export type StrokeSharpness = "round" | "sharp";
|
||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
|
||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||
@@ -56,7 +56,6 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { AppState } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
@@ -22,8 +20,6 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
|
||||
type Props = {
|
||||
setToast: (message: string) => void;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
};
|
||||
const CustomStats = (props: Props) => {
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
@@ -35,7 +31,7 @@ const CustomStats = (props: Props) => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
}, [props.elements, props.appState]);
|
||||
});
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
const version = getVersion();
|
||||
|
||||
@@ -33,8 +33,8 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG: "collabLinkForceLoadFlag",
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
} as const;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
@@ -224,6 +225,18 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
preventUnload(event);
|
||||
}
|
||||
|
||||
if (this.isCollaborating || this.portal.roomId) {
|
||||
try {
|
||||
localStorage?.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
room: this.portal.roomId,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import throttle from "lodash.throttle";
|
||||
import { throttle } from "lodash";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
@@ -153,7 +152,7 @@ class Portal {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
parent: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
|
||||
@@ -7,7 +6,7 @@ export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
@@ -72,8 +71,8 @@ export const reconcileElements = (
|
||||
const local = localElementsData[remoteElement.id];
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
if (remoteElement.parent) {
|
||||
delete remoteElement.parent;
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -93,12 +92,10 @@ export const reconcileElements = (
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
delete remoteElement.parent;
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import polyfill from "../polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
APP_NAME,
|
||||
COOKIES,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
@@ -18,7 +16,6 @@ import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
@@ -86,7 +83,6 @@ import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
|
||||
polyfill();
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
@@ -100,7 +96,6 @@ languageDetector.init({
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
@@ -185,34 +180,8 @@ const initializeScene = async (opts: {
|
||||
}
|
||||
|
||||
if (roomLinkData) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
||||
return {
|
||||
// when collaborating, the state may have already been updated at this
|
||||
// point (we may have received updates from other clients), so reconcile
|
||||
// elements and appState with existing state
|
||||
scene: {
|
||||
...scene,
|
||||
appState: {
|
||||
...restoreAppState(
|
||||
{
|
||||
...scene?.appState,
|
||||
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
||||
},
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
// necessary if we're invoking from a hashchange handler which doesn't
|
||||
// go through App.initializeScene() that resets this flag
|
||||
isLoading: false,
|
||||
},
|
||||
elements: reconcileElements(
|
||||
scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
},
|
||||
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
||||
isExternalScene: true,
|
||||
id: roomLinkData.roomId,
|
||||
key: roomLinkData.roomKey,
|
||||
@@ -366,9 +335,23 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
initializeScene({ collabAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...data.scene,
|
||||
// at this point the state may have already been updated (e.g. when
|
||||
// collaborating, we may have received updates from other clients)
|
||||
appState: restoreAppState(
|
||||
data.scene?.appState,
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
elements: reconcileElements(
|
||||
data.scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const onHashChange = async (event: HashChangeEvent) => {
|
||||
@@ -383,7 +366,7 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
||||
initializeScene({ collabAPI }).then((data) => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
@@ -514,21 +497,6 @@ const ExcalidrawWrapper = () => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
||||
// currently only used for body styling during init (see public/index.html),
|
||||
// but may change in the future
|
||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
||||
}, [theme]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@@ -538,8 +506,6 @@ const ExcalidrawWrapper = () => {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
setTheme(appState.theme);
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
@@ -691,15 +657,10 @@ const ExcalidrawWrapper = () => {
|
||||
[langCode],
|
||||
);
|
||||
|
||||
const renderCustomStats = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const renderCustomStats = () => {
|
||||
return (
|
||||
<CustomStats
|
||||
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -729,7 +690,6 @@ const ExcalidrawWrapper = () => {
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
@@ -759,7 +719,6 @@ const ExcalidrawWrapper = () => {
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
/>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
||||
@@ -24,7 +24,6 @@ const allLanguages: Language[] = [
|
||||
{ code: "fa-IR", label: "فارسی", rtl: true },
|
||||
{ code: "fi-FI", label: "Suomi" },
|
||||
{ code: "fr-FR", label: "Français" },
|
||||
{ code: "gl-ES ", label: "Galego" },
|
||||
{ code: "he-IL", label: "עברית", rtl: true },
|
||||
{ code: "hi-IN", label: "हिन्दी" },
|
||||
{ code: "hu-HU", label: "Magyar" },
|
||||
@@ -34,7 +33,6 @@ const allLanguages: Language[] = [
|
||||
{ code: "kab-KAB", label: "Taqbaylit" },
|
||||
{ code: "kk-KZ", label: "Қазақ тілі" },
|
||||
{ code: "ko-KR", label: "한국어" },
|
||||
{ code: "ku-TR", label: "Kurdî" },
|
||||
{ code: "lt-LT", label: "Lietuvių" },
|
||||
{ code: "lv-LV", label: "Latviešu" },
|
||||
{ code: "my-MM", label: "Burmese" },
|
||||
|
||||
+4
-4
@@ -18,8 +18,11 @@ export const CODES = {
|
||||
SLASH: "Slash",
|
||||
C: "KeyC",
|
||||
D: "KeyD",
|
||||
G: "KeyG",
|
||||
F: "KeyF",
|
||||
H: "KeyH",
|
||||
V: "KeyV",
|
||||
X: "KeyX",
|
||||
Z: "KeyZ",
|
||||
R: "KeyR",
|
||||
} as const;
|
||||
@@ -44,12 +47,9 @@ export const KEYS = {
|
||||
COMMA: ",",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
F: "f",
|
||||
G: "g",
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
O: "o",
|
||||
@@ -80,5 +80,5 @@ export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.shiftKey;
|
||||
|
||||
export const shouldRotateWithDiscreteAngle = (
|
||||
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
) => event.shiftKey;
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "إنشاء رابط",
|
||||
"label": "رابط"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع الملف غير مدعوم.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Този файлов формат не се поддържа.",
|
||||
|
||||
+245
-248
@@ -1,279 +1,276 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "পেস্ট করুন",
|
||||
"pasteCharts": "চার্ট পেস্ট করুন",
|
||||
"selectAll": "সবটা সিলেক্ট করুন",
|
||||
"multiSelect": "একাধিক সিলেক্ট করুন",
|
||||
"moveCanvas": "ক্যানভাস সরান",
|
||||
"cut": "কাট করুন",
|
||||
"copy": "কপি করুন",
|
||||
"copyAsPng": "পীএনজী ছবির মতন কপি করুন",
|
||||
"copyAsSvg": "এসভীজী ছবির মতন কপি করুন",
|
||||
"copyText": "লিখিত তথ্যের মতন কপি করুন",
|
||||
"bringForward": "অধিকতর সামনে আনুন",
|
||||
"sendToBack": "অধিকতর পিছনে নিয়ে যান",
|
||||
"bringToFront": "সবার সামনে আনুন",
|
||||
"sendBackward": "সবার পিছনে নিয়ে যান",
|
||||
"delete": "মুছা",
|
||||
"copyStyles": "ডিজাইন কপি করুন",
|
||||
"pasteStyles": "ডিজাইন পেস্ট করুন",
|
||||
"stroke": "রেখাংশ",
|
||||
"background": "পটভূমি",
|
||||
"fill": "রং",
|
||||
"strokeWidth": "রেখাংশের বেধ",
|
||||
"strokeStyle": "রেখাংশের ডিজাইন",
|
||||
"strokeStyle_solid": "পুরু",
|
||||
"strokeStyle_dashed": "পাতলা",
|
||||
"strokeStyle_dotted": "বিন্দুবিন্দু",
|
||||
"sloppiness": "ভ্রান্তি",
|
||||
"opacity": "দৃশ্যমানতা",
|
||||
"textAlign": "লেখ অনুভূমি",
|
||||
"edges": "কোণ",
|
||||
"sharp": "তীক্ষ্ণ",
|
||||
"round": "গোল",
|
||||
"arrowheads": "তীরের শীর্ষভাগ",
|
||||
"arrowhead_none": "কিছু না",
|
||||
"arrowhead_arrow": "তীর",
|
||||
"arrowhead_bar": "রেখাংশ",
|
||||
"arrowhead_dot": "বিন্দু",
|
||||
"arrowhead_triangle": "ত্রিভূজ",
|
||||
"fontSize": "লেখনীর মাত্রা",
|
||||
"fontFamily": "লেখনীর হরফ",
|
||||
"onlySelected": "শুধুমাত্র সিলেক্টকৃত",
|
||||
"withBackground": "পটভূমি সমেত",
|
||||
"exportEmbedScene": "দৃশ্য",
|
||||
"exportEmbedScene_details": "সিনের ডেটা এক্সপোর্টকৃত পীএনজী বা এসভীজী ফাইলের মধ্যে সেভ করা হবে যাতে করে পরবর্তী সময়ে আপনি এডিট করতে পারেন। তবে এতে ফাইলের সাইজ বাড়বে",
|
||||
"addWatermark": "এক্সক্যালিড্র দ্বারা প্রস্তুত",
|
||||
"handDrawn": "হাতে আঁকা",
|
||||
"normal": "স্বাভাবিক",
|
||||
"code": "কোড",
|
||||
"small": "ছোট",
|
||||
"medium": "মাঝারি",
|
||||
"large": "বড়",
|
||||
"veryLarge": "অনেক বড়",
|
||||
"solid": "দৃঢ়",
|
||||
"hachure": "ভ্রুলেখা",
|
||||
"crossHatch": "ক্রস হ্যাচ",
|
||||
"thin": "পাতলা",
|
||||
"bold": "পুরু",
|
||||
"left": "বাম",
|
||||
"center": "কেন্দ্র",
|
||||
"right": "ডান",
|
||||
"extraBold": "অতি পুরু",
|
||||
"architect": "স্থপতি",
|
||||
"artist": "শিল্পী",
|
||||
"cartoonist": "চিত্রকার",
|
||||
"fileTitle": "ফাইলের নাম",
|
||||
"colorPicker": "রং পছন্দ করুন",
|
||||
"canvasColors": "ক্যানভাসের রং",
|
||||
"canvasBackground": "ক্যানভাসের পটভূমি",
|
||||
"drawingCanvas": "ব্যবহৃত ক্যানভাস",
|
||||
"layers": "মাত্রা",
|
||||
"actions": "ক্রিয়া",
|
||||
"language": "ভাষা",
|
||||
"liveCollaboration": "যুগ্ম কার্য",
|
||||
"duplicateSelection": "সদৃশ সিলেক্ট",
|
||||
"untitled": "অনামী",
|
||||
"name": "নাম",
|
||||
"yourName": "আপনার নাম",
|
||||
"madeWithExcalidraw": "এক্সক্যালিড্র দ্বারা তৈরি",
|
||||
"group": "দল গঠন করুন",
|
||||
"ungroup": "দল বিভেদ করুন",
|
||||
"collaborators": "সহযোগী",
|
||||
"showGrid": "গ্রিড দেখান",
|
||||
"addToLibrary": "সংগ্রহে যোগ করুন",
|
||||
"removeFromLibrary": "সংগ্রহ থেকে বের করুন",
|
||||
"libraryLoadingMessage": "সংগ্রহ তৈরি হচ্ছে",
|
||||
"libraries": "সংগ্রহ দেখুন",
|
||||
"loadingScene": "দৃশ্য তৈরি হচ্ছে",
|
||||
"align": "পংক্তিবিন্যাস",
|
||||
"alignTop": "উপর পংক্তি",
|
||||
"alignBottom": "নিম্ন পংক্তি",
|
||||
"alignLeft": "বাম পংক্তি",
|
||||
"alignRight": "ডান পংক্তি",
|
||||
"centerVertically": "উলম্ব কেন্দ্রিত",
|
||||
"centerHorizontally": "অনুভূমিক কেন্দ্রিত",
|
||||
"distributeHorizontally": "অনুভূমিকভাবে বিতরণ করুন",
|
||||
"distributeVertically": "উল্লম্বভাবে বিতরণ করুন",
|
||||
"flipHorizontal": "অনুভূমিক আবর্তন",
|
||||
"flipVertical": "উলম্ব আবর্তন",
|
||||
"viewMode": "দৃশ্য",
|
||||
"paste": "",
|
||||
"pasteCharts": "",
|
||||
"selectAll": "",
|
||||
"multiSelect": "",
|
||||
"moveCanvas": "",
|
||||
"cut": "",
|
||||
"copy": "",
|
||||
"copyAsPng": "",
|
||||
"copyAsSvg": "",
|
||||
"copyText": "",
|
||||
"bringForward": "",
|
||||
"sendToBack": "",
|
||||
"bringToFront": "",
|
||||
"sendBackward": "",
|
||||
"delete": "",
|
||||
"copyStyles": "",
|
||||
"pasteStyles": "",
|
||||
"stroke": "",
|
||||
"background": "",
|
||||
"fill": "",
|
||||
"strokeWidth": "",
|
||||
"strokeStyle": "",
|
||||
"strokeStyle_solid": "",
|
||||
"strokeStyle_dashed": "",
|
||||
"strokeStyle_dotted": "",
|
||||
"sloppiness": "",
|
||||
"opacity": "",
|
||||
"textAlign": "",
|
||||
"edges": "",
|
||||
"sharp": "",
|
||||
"round": "",
|
||||
"arrowheads": "",
|
||||
"arrowhead_none": "",
|
||||
"arrowhead_arrow": "",
|
||||
"arrowhead_bar": "",
|
||||
"arrowhead_dot": "",
|
||||
"arrowhead_triangle": "",
|
||||
"fontSize": "",
|
||||
"fontFamily": "",
|
||||
"onlySelected": "",
|
||||
"withBackground": "",
|
||||
"exportEmbedScene": "",
|
||||
"exportEmbedScene_details": "",
|
||||
"addWatermark": "",
|
||||
"handDrawn": "",
|
||||
"normal": "",
|
||||
"code": "",
|
||||
"small": "",
|
||||
"medium": "",
|
||||
"large": "",
|
||||
"veryLarge": "",
|
||||
"solid": "",
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "",
|
||||
"bold": "",
|
||||
"left": "",
|
||||
"center": "",
|
||||
"right": "",
|
||||
"extraBold": "",
|
||||
"architect": "",
|
||||
"artist": "",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "",
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "",
|
||||
"drawingCanvas": "",
|
||||
"layers": "",
|
||||
"actions": "",
|
||||
"language": "",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "",
|
||||
"untitled": "",
|
||||
"name": "",
|
||||
"yourName": "",
|
||||
"madeWithExcalidraw": "",
|
||||
"group": "",
|
||||
"ungroup": "",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "ভাগ করুন",
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
"decreaseFontSize": "লেখনীর মাত্রা কমান",
|
||||
"increaseFontSize": "লেখনীর মাত্রা বাড়ান",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "লিঙ্ক সংশোধন",
|
||||
"create": "লিঙ্ক তৈরী",
|
||||
"label": "লিঙ্ক নামকরণ"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "আবদ্ধ করুন",
|
||||
"unlock": "বিচ্ছিন্ন করুন",
|
||||
"lockAll": "সব আবদ্ধ করুন",
|
||||
"unlockAll": "সব বিচ্ছিন্ন করুন"
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": "প্রকাশিত",
|
||||
"sidebarLock": "লক"
|
||||
"statusPublished": "",
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "সংগ্রহে কিছু যোগ করা হয়নি",
|
||||
"hint_emptyLibrary": "এখানে যোগ করার জন্য ক্যানভাসে একটি বস্তু নির্বাচন করুন, অথবা নীচে, প্রকাশ্য সংগ্রহশালা থেকে একটি সংগ্রহ ইনস্টল করুন৷",
|
||||
"hint_emptyPrivateLibrary": "এখানে যোগ করার জন্য ক্যানভাসে একটি বস্তু নির্বাচন করুন"
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "ক্যানভাস সাফ করুন",
|
||||
"exportJSON": "জেসন নিবদ্ধ করুন",
|
||||
"exportImage": "চিত্র নিবদ্ধ করুন",
|
||||
"export": "নিবদ্ধ",
|
||||
"exportToPng": "পীএনজী ছবির মতন নিবদ্ধ করুন",
|
||||
"exportToSvg": "এসভীজী ছবির মতন নিবদ্ধ করুন",
|
||||
"copyToClipboard": "ক্লিপবোর্ডে কপি করুন",
|
||||
"copyPngToClipboard": "পীএনজী ছবির মতন ক্লিপবোর্ডে কপি করুন",
|
||||
"scale": "মাপ",
|
||||
"save": "জমা করুন",
|
||||
"saveAs": "অন্যভাবে জমা করুন",
|
||||
"load": "লোড করুন",
|
||||
"getShareableLink": "ভাগযোগ্য লিঙ্ক পান",
|
||||
"close": "বন্ধ করুন",
|
||||
"selectLanguage": "ভাষা চিহ্নিত করুন",
|
||||
"scrollBackToContent": "বিষয়বস্তুতে ফেরত যান",
|
||||
"zoomIn": "বড় করুন",
|
||||
"zoomOut": "ছোট করুন",
|
||||
"resetZoom": "স্বাভাবিক করুন",
|
||||
"menu": "তালিকা",
|
||||
"done": "সম্পন্ন",
|
||||
"edit": "সংশোধন করুন",
|
||||
"undo": "ফেরত যান",
|
||||
"redo": "পুনরায় করুন",
|
||||
"resetLibrary": "সংগ্রহ সাফ করুন",
|
||||
"createNewRoom": "নতুন রুম বানান",
|
||||
"fullScreen": "পূর্ণস্ক্রীন",
|
||||
"darkMode": "ডার্ক মোড",
|
||||
"lightMode": "লাইট মোড",
|
||||
"zenMode": "জেন মোড",
|
||||
"exitZenMode": "জেন মোড বন্ধ করুন",
|
||||
"cancel": "বাতিল",
|
||||
"clear": "সাফ",
|
||||
"remove": "বিয়োগ",
|
||||
"publishLibrary": "সংগ্রহ প্রকাশ করুন",
|
||||
"submit": "জমা করুন",
|
||||
"confirm": "নিশ্চিত করুন"
|
||||
"clearReset": "",
|
||||
"exportJSON": "",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyToClipboard": "",
|
||||
"copyPngToClipboard": "",
|
||||
"scale": "",
|
||||
"save": "",
|
||||
"saveAs": "",
|
||||
"load": "",
|
||||
"getShareableLink": "",
|
||||
"close": "",
|
||||
"selectLanguage": "",
|
||||
"scrollBackToContent": "",
|
||||
"zoomIn": "",
|
||||
"zoomOut": "",
|
||||
"resetZoom": "",
|
||||
"menu": "",
|
||||
"done": "",
|
||||
"edit": "",
|
||||
"undo": "",
|
||||
"redo": "",
|
||||
"resetLibrary": "",
|
||||
"createNewRoom": "",
|
||||
"fullScreen": "",
|
||||
"darkMode": "",
|
||||
"lightMode": "",
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "এটি পুরো ক্যানভাস সাফ করবে। আপনি কি নিশ্চিত?",
|
||||
"couldNotCreateShareableLink": "ভাগ করা যায় এমন লিঙ্ক তৈরি করা যায়নি।",
|
||||
"couldNotCreateShareableLinkTooBig": "ভাগ করা যায় এমন লিঙ্ক তৈরি করা যায়নি: দৃশ্যটি খুব বড়",
|
||||
"couldNotLoadInvalidFile": "অবৈধ ফাইল লোড করা যায়নি",
|
||||
"importBackendFailed": "ব্যাকেন্ড থেকে আপলোড ব্যর্থ হয়েছে।",
|
||||
"cannotExportEmptyCanvas": "খালি ক্যানভাস নিবদ্ধ করা যাবে না।",
|
||||
"couldNotCopyToClipboard": "ক্লিপবোর্ডে কপি করা যায়নি।",
|
||||
"decryptFailed": "তথ্য ডিক্রিপ্ট করা যায়নি।",
|
||||
"uploadedSecurly": "আপলোডটি এন্ড-টু-এন্ড এনক্রিপশনের মাধ্যমে সুরক্ষিত করা হয়েছে, যার অর্থ হল এক্সক্যালিড্র সার্ভার এবং তৃতীয় পক্ষের দ্বারা পড়তে পারা সম্ভব নয়।",
|
||||
"loadSceneOverridePrompt": "বাহ্যিক অঙ্কন লোড করা আপনার বিদ্যমান দৃশ্য প্রতিস্থাপন করবে। আপনি কি অবিরত করতে চান?",
|
||||
"collabStopOverridePrompt": "অধিবেশন বন্ধ করা আপনার পূর্ববর্তী, স্থানীয়ভাবে সঞ্চিত অঙ্কন ওভাররাইট করবে। আপনি কি নিশ্চিত?\n\n(যদি আপনি আপনার স্থানীয় অঙ্কন রাখতে চান, তাহলে শুধু ব্রাউজার ট্যাবটি বন্ধ করুন।)",
|
||||
"errorAddingToLibrary": "বস্তুটি সংগ্রহে যোগ করা যায়নি",
|
||||
"errorRemovingFromLibrary": "বস্তুটি সংগ্রহ থেকে বিয়োগ করা যায়নি",
|
||||
"confirmAddLibrary": "এটি আপনার সংগ্রহে {{numShapes}} আকার(গুলি) যোগ করবে। আপনি কি নিশ্চিত?",
|
||||
"imageDoesNotContainScene": "এই ছবিতে কোনো দৃশ্যের তথ্য আছে বলে মনে হয় না৷ আপনি কি নিবদ্ধ করার সময় দৃশ্য এমবেডিং করতে সক্ষম?",
|
||||
"cannotRestoreFromImage": "এই ফাইল থেকে দৃশ্য পুনরুদ্ধার করা যায়নি",
|
||||
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
|
||||
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
|
||||
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
|
||||
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।"
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "",
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "অসমর্থিত ফাইল।",
|
||||
"imageInsertError": "ছবি সন্নিবেশ করা যায়নি। পরে আবার চেষ্টা করুন...",
|
||||
"fileTooBig": "ফাইলটি খুব বড়। সর্বাধিক অনুমোদিত আকার হল {{maxSize}}৷",
|
||||
"svgImageInsertError": "এসভীজী ছবি সন্নিবেশ করা যায়নি। এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"invalidSVGString": "এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।",
|
||||
"importLibraryError": "সংগ্রহ লোড করা যায়নি"
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "বাছাই",
|
||||
"image": "চিত্র সন্নিবেশ",
|
||||
"rectangle": "আয়তক্ষেত্র",
|
||||
"diamond": "রুহিতন",
|
||||
"ellipse": "উপবৃত্ত",
|
||||
"arrow": "তীর",
|
||||
"line": "রেখা",
|
||||
"freedraw": "কলম",
|
||||
"text": "লেখা",
|
||||
"library": "সংগ্রহ",
|
||||
"lock": "আঁকার পরে নির্বাচিত টুল সক্রিয় রাখুন",
|
||||
"penMode": "পিঞ্চ-জুম প্রতিরোধ করুন এবং শুধুমাত্র কলম থেকে ইনপুট গ্রহণ করুন",
|
||||
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
|
||||
"eraser": "ঝাড়ন"
|
||||
"selection": "",
|
||||
"image": "",
|
||||
"rectangle": "",
|
||||
"diamond": "",
|
||||
"ellipse": "",
|
||||
"arrow": "",
|
||||
"line": "",
|
||||
"freedraw": "",
|
||||
"text": "",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "ক্যানভাস কার্যকলাপ",
|
||||
"selectedShapeActions": "বাছাই করা আকার(গুলি)র কার্যকলাপ",
|
||||
"shapes": "আকার(গুলি)"
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"shapes": ""
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "ক্যানভাস সরানোর জন্য মাউস হুইল বা স্পেসবার ধরে টানুন",
|
||||
"linearElement": "একাধিক বিন্দু শুরু করতে ক্লিক করুন, একক লাইনের জন্য টেনে আনুন",
|
||||
"freeDraw": "ক্লিক করুন এবং টেনে আনুন, আপনার কাজ শেষ হলে ছেড়ে দিন",
|
||||
"text": "বিশেষ্য: আপনি নির্বাচন টুলের সাথে যে কোনো জায়গায় ডাবল-ক্লিক করে পাঠ্য যোগ করতে পারেন",
|
||||
"text_selected": "লেখা সম্পাদনা করতে ডাবল-ক্লিক করুন বা এন্টার টিপুন",
|
||||
"text_editing": "লেখা সম্পাদনা শেষ করতে এসকেপ বা কন্ট্রোল/কম্যান্ড যোগে এন্টার টিপুন",
|
||||
"linearElementMulti": "শেষ বিন্দুতে ক্লিক করুন অথবা শেষ করতে এসকেপ বা এন্টার টিপুন",
|
||||
"lockAngle": "ঘোরানোর সময় আপনি শিফ্ট ধরে রেখে কোণ সীমাবদ্ধ করতে পারেন",
|
||||
"resize": "আপনি আকার পরিবর্তন করার সময় শিফ্ট ধরে রেখে অনুপাতকে সীমাবদ্ধ করতে পারেন,\nকেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরে রাখুন",
|
||||
"resizeImage": "আপনি শিফ্ট ধরে রেখে অবাধে আকার পরিবর্তন করতে পারেন, কেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরুন",
|
||||
"rotate": "আপনি ঘোরানোর সময় শিফ্ট ধরে রেখে কোণগুলিকে সীমাবদ্ধ করতে পারেন",
|
||||
"lineEditor_info": "পয়েন্ট সম্পাদনা করতে ডাবল-ক্লিক করুন বা এন্টার টিপুন",
|
||||
"lineEditor_pointSelected": "বিন্দু(গুলি) মুছতে ডিলিট টিপুন, কন্ট্রোল/কম্যান্ড যোগে ডি টিপুন নকল করতে অথবা সরানোর জন্য টানুন",
|
||||
"lineEditor_nothingSelected": "সম্পাদনা করার জন্য একটি বিন্দু নির্বাচন করুন (একাধিক নির্বাচন করতে শিফ্ট ধরে রাখুন),\nঅথবা অল্ট ধরে রাখুন এবং নতুন বিন্দু যোগ করতে ক্লিক করুন",
|
||||
"placeImage": "ছবিটি স্থাপন করতে ক্লিক করুন, অথবা নিজে আকার সেট করতে ক্লিক করুন এবং টেনে আনুন",
|
||||
"publishLibrary": "আপনার নিজস্ব সংগ্রহ প্রকাশ করুন",
|
||||
"bindTextToElement": "লেখা যোগ করতে এন্টার টিপুন",
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "মুছে ফেলার জন্য চিহ্নিত উপাদানগুলিকে ফিরিয়ে আনতে অল্ট ধরে রাখুন"
|
||||
"eraserRevert": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "প্রিভিউ দেখাতে অপারগ",
|
||||
"canvasTooBig": "ক্যানভাস অনেক বড়।",
|
||||
"canvasTooBigTip": "বিশেষ্য: দূরতম উপাদানগুলোকে একটু কাছাকাছি নিয়ে যাওয়ার চেষ্টা করুন।"
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain_pre": "একটি ত্রুটির সম্মুখীন হয়েছে৷ চেষ্টা করুন ",
|
||||
"headingMain_button": "পৃষ্ঠাটি পুনরায় লোড করার।",
|
||||
"clearCanvasMessage": "যদি পুনরায় লোড করা কাজ না করে, চেষ্টা করুন ",
|
||||
"clearCanvasMessage_button": "ক্যানভাস পরিষ্কার করার।",
|
||||
"clearCanvasCaveat": " এর ফলে কাজের ক্ষতি হবে ",
|
||||
"trackedToSentry_pre": "ত্রুটি ",
|
||||
"trackedToSentry_post": " আমাদের সিস্টেমে ট্র্যাক করা হয়েছিল।",
|
||||
"openIssueMessage_pre": "আমরা ত্রুটিতে আপনার দৃশ্যের তথ্য অন্তর্ভুক্ত না করার জন্য খুব সতর্ক ছিলাম। আপনার দৃশ্য ব্যক্তিগত না হলে, আমাদের অনুসরণ করার কথা বিবেচনা করুন ",
|
||||
"openIssueMessage_button": "ত্রুটি ইতিবৃত্ত।",
|
||||
"openIssueMessage_post": " অনুগ্রহ করে GitHub ইস্যুতে অনুলিপি এবং পেস্ট করে নীচের তথ্য অন্তর্ভুক্ত করুন।",
|
||||
"sceneContent": "দৃশ্য বিষয়বস্তু:"
|
||||
"headingMain_pre": "",
|
||||
"headingMain_button": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasMessage_button": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"trackedToSentry_pre": "",
|
||||
"trackedToSentry_post": "",
|
||||
"openIssueMessage_pre": "",
|
||||
"openIssueMessage_button": "",
|
||||
"openIssueMessage_post": "",
|
||||
"sceneContent": ""
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "আপনি আপনার সাথে সহযোগিতা করার জন্য আপনার বর্তমান দৃশ্যে লোকেদের আমন্ত্রণ জানাতে পারেন৷",
|
||||
"desc_privacy": "চিন্তা করবেন না, সেশনটি এন্ড-টু-এন্ড এনক্রিপশন ব্যবহার করে, তাই আপনি যা আঁকবেন তা গোপন থাকবে। এমনকি আমাদের সার্ভার আপনি যা নিয়ে এসেছেন তা দেখতে সক্ষম হবে না।",
|
||||
"button_startSession": "সেশন শুরু করুন",
|
||||
"button_stopSession": "সেশন বন্ধ করুন",
|
||||
"desc_inProgressIntro": "লাইভ-সহযোগীতার সেশন এখন চলছে।",
|
||||
"desc_shareLink": "আপনি যার সাথে সহযোগিতা করতে চান তাদের সাথে এই লিঙ্কটি ভাগ করুন: ",
|
||||
"desc_exitSession": "অধিবেশন বন্ধ করা আপনাকে রুম থেকে সংযোগ বিচ্ছিন্ন করবে, কিন্তু আপনি স্থানীয়ভাবে দৃশ্যের সাথে কাজ চালিয়ে যেতে সক্ষম হবেন। মনে রাখবেন যে এটি অন্য লোকেদের প্রভাবিত করবে না এবং তারা এখনও তাদের সংস্করণে সহযোগিতা করতে সক্ষম হবে।",
|
||||
"shareTitle": "এক্সক্যালিড্র লাইভ সহযোগিতা সেশনে যোগ দিন"
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "ত্রুটি"
|
||||
"title": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
@@ -283,12 +280,12 @@
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "নিবদ্ধ",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "",
|
||||
"click": "ক্লিক",
|
||||
"click": "",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "",
|
||||
@@ -300,7 +297,7 @@
|
||||
"editSelectedShape": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "অথবা",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
@@ -369,7 +366,7 @@
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "কোণ",
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
@@ -381,20 +378,20 @@
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": "প্রস্থ"
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "সংগ্রহশালায় যুক্ত হয়েছে",
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "ক্লিপবোর্ডে কপি করা হয়েছে।",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "",
|
||||
"selection": "বাছাই"
|
||||
"selection": ""
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "সাদা",
|
||||
"ffffff": "",
|
||||
"f8f9fa": "",
|
||||
"f1f3f5": "",
|
||||
"fff5f5": "",
|
||||
@@ -424,7 +421,7 @@
|
||||
"82c91e": "",
|
||||
"fab005": "",
|
||||
"fd7e14": "",
|
||||
"000000": "কালো",
|
||||
"000000": "",
|
||||
"343a40": "",
|
||||
"495057": "",
|
||||
"c92a2a": "",
|
||||
|
||||
+10
-13
@@ -108,16 +108,12 @@
|
||||
"decreaseFontSize": "Redueix la mida de la lletra",
|
||||
"increaseFontSize": "Augmenta la mida de la lletra",
|
||||
"unbindText": "Desvincular el text",
|
||||
"bindText": "Ajusta el text al contenidor",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "Edita l'enllaç",
|
||||
"create": "Crea un enllaç",
|
||||
"label": "Enllaç"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloca",
|
||||
"unlock": "Desbloca",
|
||||
@@ -125,12 +121,12 @@
|
||||
"unlockAll": "Desbloca-ho tot"
|
||||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": "Manté la barra lateral oberta"
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Encara no s'hi han afegit elements...",
|
||||
"hint_emptyLibrary": "Trieu un element o un llenç per a afegir-lo aquí, o instal·leu una biblioteca del repositori públic, més avall.",
|
||||
"hint_emptyPrivateLibrary": "Trieu un element o un llenç per a afegir-lo aquí."
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Neteja el llenç",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
|
||||
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
|
||||
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada."
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipus de fitxer no suportat.",
|
||||
@@ -199,7 +196,7 @@
|
||||
"fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.",
|
||||
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
|
||||
"invalidSVGString": "SVG no vàlid.",
|
||||
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "No s'ha pogut carregar la biblioteca"
|
||||
},
|
||||
"toolBar": {
|
||||
@@ -310,7 +307,7 @@
|
||||
"view": "Visualització",
|
||||
"zoomToFit": "Zoom per veure tots els elements",
|
||||
"zoomToSelection": "Zoom per veure la selecció",
|
||||
"toggleElementLock": "Blocar/desblocar la selecció"
|
||||
"toggleElementLock": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Neteja el llenç"
|
||||
@@ -328,7 +325,7 @@
|
||||
"authorName": "Nom o usuari",
|
||||
"libraryName": "Nom de la vostra biblioteca",
|
||||
"libraryDesc": "Descripció de la biblioteca per a ajudar a la gent a entendre'n el funcionament",
|
||||
"githubHandle": "Identificador de GitHub (opcional), per tal que pugueu editar la biblioteca una vegada enviada per a ser revisada",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "Usuari de twitter (opcional), per tal que puguem donar-vos crèdit quan fem la promoció a Twitter",
|
||||
"website": "Enllaç al vostre lloc web personal o a qualsevol altre (opcional)"
|
||||
},
|
||||
|
||||
+161
-164
@@ -20,7 +20,7 @@
|
||||
"stroke": "Obrys",
|
||||
"background": "Pozadí",
|
||||
"fill": "Výplň",
|
||||
"strokeWidth": "Tloušťka tahu",
|
||||
"strokeWidth": "Šířka obrysu",
|
||||
"strokeStyle": "Styl tahu",
|
||||
"strokeStyle_solid": "Plný",
|
||||
"strokeStyle_dashed": "Čárkovaný",
|
||||
@@ -51,52 +51,52 @@
|
||||
"medium": "Střední",
|
||||
"large": "Velké",
|
||||
"veryLarge": "Velmi velké",
|
||||
"solid": "Plný",
|
||||
"solid": "",
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "Tenký",
|
||||
"bold": "Tlustý",
|
||||
"left": "Vlevo",
|
||||
"center": "Na střed",
|
||||
"right": "Vpravo",
|
||||
"extraBold": "Extra tlustý",
|
||||
"thin": "",
|
||||
"bold": "",
|
||||
"left": "",
|
||||
"center": "",
|
||||
"right": "",
|
||||
"extraBold": "",
|
||||
"architect": "",
|
||||
"artist": "",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "Název souboru",
|
||||
"colorPicker": "Výběr barvy",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "",
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "Pozadí plátna",
|
||||
"drawingCanvas": "",
|
||||
"layers": "Vrstvy",
|
||||
"actions": "Akce",
|
||||
"language": "Jazyk",
|
||||
"liveCollaboration": "Živá spolupráce",
|
||||
"duplicateSelection": "Duplikovat",
|
||||
"untitled": "Bez názvu",
|
||||
"name": "Název",
|
||||
"yourName": "Vaše jméno",
|
||||
"madeWithExcalidraw": "Vytvořeno v Excalidraw",
|
||||
"group": "Sloučit výběr do skupiny",
|
||||
"ungroup": "Zrušit sloučení skupiny",
|
||||
"collaborators": "Spolupracovníci",
|
||||
"showGrid": "Zobrazit mřížku",
|
||||
"addToLibrary": "Přidat do knihovny",
|
||||
"removeFromLibrary": "Odebrat z knihovny",
|
||||
"libraryLoadingMessage": "Načítání knihovny…",
|
||||
"libraries": "Procházet knihovny",
|
||||
"loadingScene": "Načítání scény…",
|
||||
"align": "Zarovnání",
|
||||
"alignTop": "Zarovnat nahoru",
|
||||
"alignBottom": "Zarovnat dolů",
|
||||
"alignLeft": "Zarovnat vlevo",
|
||||
"alignRight": "Zarovnejte vpravo",
|
||||
"centerVertically": "Vycentrovat svisle",
|
||||
"centerHorizontally": "Vycentrovat vodorovně",
|
||||
"distributeHorizontally": "Rozložit horizontálně",
|
||||
"distributeVertically": "Rozložit svisle",
|
||||
"flipHorizontal": "Převrátit vodorovně",
|
||||
"flipVertical": "Převrátit svisle",
|
||||
"layers": "",
|
||||
"actions": "",
|
||||
"language": "",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "",
|
||||
"untitled": "",
|
||||
"name": "",
|
||||
"yourName": "",
|
||||
"madeWithExcalidraw": "",
|
||||
"group": "",
|
||||
"ungroup": "",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "Náhled",
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "Sdílet",
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -164,18 +160,18 @@
|
||||
"lightMode": "Světlý režim",
|
||||
"zenMode": "Zen mód",
|
||||
"exitZenMode": "Opustit zen mód",
|
||||
"cancel": "Zrušit",
|
||||
"clear": "Vyčistit",
|
||||
"remove": "Odstranit",
|
||||
"publishLibrary": "Zveřejnit",
|
||||
"submit": "Odeslat",
|
||||
"confirm": "Potvrdit"
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Toto vymaže celé plátno. Jste si jisti?",
|
||||
"couldNotCreateShareableLink": "Nepodařilo se vytvořit sdílitelný odkaz.",
|
||||
"couldNotCreateShareableLinkTooBig": "Nepodařilo se vytvořit sdílený odkaz: scéna je příliš velká",
|
||||
"couldNotLoadInvalidFile": "Nepodařilo se načíst neplatný soubor",
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
@@ -216,7 +213,7 @@
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "Guma"
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
@@ -278,72 +275,72 @@
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_button": "Uložit do souboru",
|
||||
"link_title": "Odkaz pro sdílení",
|
||||
"link_details": "Exportovat jako odkaz pouze pro čtení.",
|
||||
"link_button": "Exportovat do odkazu",
|
||||
"excalidrawplus_description": "Uložit scénu do vašeho pracovního prostoru Excalidraw+.",
|
||||
"excalidrawplus_button": "Exportovat",
|
||||
"excalidrawplus_exportError": "Export do Excalidraw+ se v tuto chvíli nezdařil..."
|
||||
"disk_button": "",
|
||||
"link_title": "",
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "Přečtěte si náš blog",
|
||||
"blog": "",
|
||||
"click": "kliknutí",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "Zakřivená šipka",
|
||||
"curvedLine": "Zakřivená čára",
|
||||
"documentation": "Dokumentace",
|
||||
"doubleClick": "dvojklik",
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"doubleClick": "",
|
||||
"drag": "tažení",
|
||||
"editor": "Editor",
|
||||
"editor": "",
|
||||
"editSelectedShape": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "nebo",
|
||||
"preventBinding": "Zabránit vázání šipky",
|
||||
"tools": "Nástroje",
|
||||
"shortcuts": "Klávesové zkratky",
|
||||
"textFinish": "Dokončit úpravy (textový editor)",
|
||||
"textNewLine": "Přidat nový řádek (textový editor)",
|
||||
"title": "Nápověda",
|
||||
"view": "Zobrazení",
|
||||
"zoomToFit": "Přiblížit na zobrazení všech prvků",
|
||||
"zoomToSelection": "Přiblížit na výběr",
|
||||
"toggleElementLock": "Zamknout/odemknout výběr"
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
"textFinish": "",
|
||||
"textNewLine": "",
|
||||
"title": "",
|
||||
"view": "",
|
||||
"zoomToFit": "",
|
||||
"zoomToSelection": "",
|
||||
"toggleElementLock": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Vymazat plátno"
|
||||
"title": ""
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Publikovat knihovnu",
|
||||
"itemName": "Název položky",
|
||||
"authorName": "Jméno autora",
|
||||
"githubUsername": "GitHub uživatelské jméno",
|
||||
"twitterUsername": "Twitter uživatelské jméno",
|
||||
"libraryName": "Název knihovny",
|
||||
"libraryDesc": "Popis knihovny",
|
||||
"website": "Webová stránka",
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"authorName": "",
|
||||
"githubUsername": "",
|
||||
"twitterUsername": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"website": "",
|
||||
"placeholder": {
|
||||
"authorName": "Jméno nebo uživatelské jméno",
|
||||
"libraryName": "Název vaší knihovny",
|
||||
"libraryDesc": "Popis Vaší knihovny, který pomůže lidem pochopit její využití",
|
||||
"githubHandle": "Github uživatelské jméno (nepovinné), abyste mohli upravovat knihovnu poté co je odeslána ke kontrole",
|
||||
"twitterHandle": "Twitter uživatelské jméno (nepovinné), abychom věděli koho označit při propagaci na Twitteru",
|
||||
"website": "Odkaz na Vaši osobní webovou stránku nebo jinam (nepovinné)"
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "Povinné",
|
||||
"website": "Zadejte platnou URL adresu"
|
||||
"required": "",
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "Odešlete svou knihovnu, pro zařazení do ",
|
||||
"link": "veřejného úložiště knihoven",
|
||||
"post": ", odkud ji budou moci při kreslení využít i ostatní uživatelé."
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím ",
|
||||
"link": "pokyny",
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteLicense": {
|
||||
@@ -356,7 +353,7 @@
|
||||
"republishWarning": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Knihovna byla odeslána",
|
||||
"title": "",
|
||||
"content": "",
|
||||
"link": ""
|
||||
},
|
||||
@@ -369,75 +366,75 @@
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Úhel",
|
||||
"element": "Prvek",
|
||||
"elements": "Prvky",
|
||||
"height": "Výška",
|
||||
"scene": "Scéna",
|
||||
"selected": "Vybráno",
|
||||
"storage": "Úložiště",
|
||||
"title": "Statistika pro nerdy",
|
||||
"total": "Celkem",
|
||||
"version": "Verze",
|
||||
"versionCopy": "Kliknutím zkopírujete",
|
||||
"versionNotAvailable": "Verze není k dispozici",
|
||||
"width": "Šířka"
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
"scene": "",
|
||||
"selected": "",
|
||||
"storage": "",
|
||||
"title": "",
|
||||
"total": "",
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Přidáno do knihovny",
|
||||
"copyStyles": "Styly byly zkopírovány.",
|
||||
"copyToClipboard": "Zkopírováno do schránky.",
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "Soubor byl uložen.",
|
||||
"fileSavedToFilename": "Uloženo do {filename}",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "plátno",
|
||||
"selection": "výběr"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "Bílá",
|
||||
"f8f9fa": "Šedá 0",
|
||||
"f1f3f5": "Šedá 1",
|
||||
"fff5f5": "Červená 0",
|
||||
"fff0f6": "Růžová 0",
|
||||
"f8f0fc": "Vínová 0",
|
||||
"f3f0ff": "Fialová 0",
|
||||
"edf2ff": "Indigová 0",
|
||||
"e7f5ff": "Modrá 0",
|
||||
"e3fafc": "Azurová 0",
|
||||
"e6fcf5": "Modrozelená 0",
|
||||
"ebfbee": "Zelená 0",
|
||||
"f4fce3": "Limetková 0",
|
||||
"fff9db": "Žlutá 0",
|
||||
"fff4e6": "Oranžová 0",
|
||||
"transparent": "Průhledná",
|
||||
"ced4da": "Šedá 4",
|
||||
"868e96": "Šedá 6",
|
||||
"fa5252": "Červená 6",
|
||||
"e64980": "Růžová 6",
|
||||
"be4bdb": "Vínová 6",
|
||||
"7950f2": "Fialová 6",
|
||||
"4c6ef5": "Indigová 6",
|
||||
"228be6": "Modrá 6",
|
||||
"15aabf": "Azurová 6",
|
||||
"12b886": "Modrozelená 6",
|
||||
"40c057": "Zelená 6",
|
||||
"82c91e": "Limetková 6",
|
||||
"fab005": "Žlutá 6",
|
||||
"fd7e14": "Oranžová 6",
|
||||
"000000": "Černá",
|
||||
"343a40": "Šedá 8",
|
||||
"495057": "Šedá 7",
|
||||
"c92a2a": "Červená 9",
|
||||
"a61e4d": "Růžová 9",
|
||||
"862e9c": "Vínová 9",
|
||||
"5f3dc4": "Fialová 9",
|
||||
"364fc7": "Indigová 9",
|
||||
"1864ab": "Modrá 9",
|
||||
"0b7285": "Azurová 9",
|
||||
"087f5b": "Modrozelená 9",
|
||||
"2b8a3e": "Zelená 9",
|
||||
"5c940d": "Limetková 9",
|
||||
"e67700": "Žlutá 9",
|
||||
"d9480f": "Oranzova"
|
||||
"ffffff": "",
|
||||
"f8f9fa": "",
|
||||
"f1f3f5": "",
|
||||
"fff5f5": "",
|
||||
"fff0f6": "",
|
||||
"f8f0fc": "",
|
||||
"f3f0ff": "",
|
||||
"edf2ff": "",
|
||||
"e7f5ff": "",
|
||||
"e3fafc": "",
|
||||
"e6fcf5": "",
|
||||
"ebfbee": "",
|
||||
"f4fce3": "",
|
||||
"fff9db": "",
|
||||
"fff4e6": "",
|
||||
"transparent": "",
|
||||
"ced4da": "",
|
||||
"868e96": "",
|
||||
"fa5252": "",
|
||||
"e64980": "",
|
||||
"be4bdb": "",
|
||||
"7950f2": "",
|
||||
"4c6ef5": "",
|
||||
"228be6": "",
|
||||
"15aabf": "",
|
||||
"12b886": "",
|
||||
"40c057": "",
|
||||
"82c91e": "",
|
||||
"fab005": "",
|
||||
"fd7e14": "",
|
||||
"000000": "",
|
||||
"343a40": "",
|
||||
"495057": "",
|
||||
"c92a2a": "",
|
||||
"a61e4d": "",
|
||||
"862e9c": "",
|
||||
"5f3dc4": "",
|
||||
"364fc7": "",
|
||||
"1864ab": "",
|
||||
"0b7285": "",
|
||||
"087f5b": "",
|
||||
"2b8a3e": "",
|
||||
"5c940d": "",
|
||||
"e67700": "",
|
||||
"d9480f": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Link erstellen",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Linie bearbeiten",
|
||||
"exit": "Linieneditor verlassen"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Sperren",
|
||||
"unlock": "Entsperren",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
|
||||
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
|
||||
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.",
|
||||
"browserZoom": "Die Zoomstufe Deines Browsers ist nicht auf 100% gesetzt, was dazu führen kann, dass der Zeichenbereich falsch angezeigt wird"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nicht unterstützter Dateityp.",
|
||||
|
||||
+67
-70
@@ -9,7 +9,7 @@
|
||||
"copy": "Αντιγραφή",
|
||||
"copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG",
|
||||
"copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG",
|
||||
"copyText": "Αντιγραφή στο πρόχειρο ως κείμενο",
|
||||
"copyText": "",
|
||||
"bringForward": "Στο προσκήνιο",
|
||||
"sendToBack": "Ένα επίπεδο πίσω",
|
||||
"bringToFront": "Ένα επίπεδο μπροστά",
|
||||
@@ -41,7 +41,7 @@
|
||||
"fontFamily": "Γραμματοσειρά",
|
||||
"onlySelected": "Μόνο τα Επιλεγμένα",
|
||||
"withBackground": "Φόντο",
|
||||
"exportEmbedScene": "Ενσωμάτωση σκηνής",
|
||||
"exportEmbedScene": "",
|
||||
"exportEmbedScene_details": "Τα δεδομένα σκηνής θα αποθηκευτούν στο αρχείο PNG/SVG προς εξαγωγή ώστε η σκηνή να είναι δυνατό να αποκατασταθεί από αυτό.\nΘα αυξήσει το μέγεθος του αρχείου προς εξαγωγή.",
|
||||
"addWatermark": "Προσθήκη \"Φτιαγμένο με Excalidraw\"",
|
||||
"handDrawn": "Σχεδιασμένο στο χέρι",
|
||||
@@ -65,7 +65,7 @@
|
||||
"cartoonist": "Σκιτσογράφος",
|
||||
"fileTitle": "Όνομα αρχείου",
|
||||
"colorPicker": "Επιλογή Χρώματος",
|
||||
"canvasColors": "Χρησιμοποείται στον καμβά",
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "Φόντο καμβά",
|
||||
"drawingCanvas": "Σχεδίαση καμβά",
|
||||
"layers": "Στρώματα",
|
||||
@@ -105,32 +105,28 @@
|
||||
"toggleTheme": "Εναλλαγή θέματος",
|
||||
"personalLib": "Προσωπική Βιβλιοθήκη",
|
||||
"excalidrawLib": "Βιβλιοθήκη Excalidraw",
|
||||
"decreaseFontSize": "Μείωση μεγέθους γραμματοσειράς",
|
||||
"increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς",
|
||||
"unbindText": "Αποσύνδεση κειμένου",
|
||||
"bindText": "Δέσμευση κειμένου στο δοχείο",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "Επεξεργασία συνδέσμου",
|
||||
"create": "Δημιουργία συνδέσμου",
|
||||
"label": "Σύνδεσμος"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Κλείδωμα",
|
||||
"unlock": "Ξεκλείδωμα",
|
||||
"lockAll": "Κλείδωμα όλων",
|
||||
"unlockAll": "Ξεκλείδωμα όλων"
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": "Δημοσιευμένο",
|
||||
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή"
|
||||
"statusPublished": "",
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Δεν έχουν προστεθεί αντικείμενα ακόμη...",
|
||||
"hint_emptyLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ, ή εγκαταστήστε μια βιβλιοθήκη από το δημόσιο αποθετήριο, παρακάτω.",
|
||||
"hint_emptyPrivateLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ."
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Επαναφορά του καμβά",
|
||||
@@ -178,7 +174,7 @@
|
||||
"couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο",
|
||||
"importBackendFailed": "Η εισαγωγή από το backend απέτυχε.",
|
||||
"cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.",
|
||||
"couldNotCopyToClipboard": "Αδυναμία αντιγραφής στο πρόχειρο.",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
|
||||
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
|
||||
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
|
||||
@@ -186,21 +182,22 @@
|
||||
"errorAddingToLibrary": "Αδυναμία προσθήκης αντικειμένου στη βιβλιοθήκη",
|
||||
"errorRemovingFromLibrary": "Αδυναμία αφαίρεσης αντικειμένου από τη βιβλιοθήκη",
|
||||
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"imageDoesNotContainScene": "Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει την ενσωμάτωση σκηνής κατά την εξαγωγή;",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
|
||||
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
|
||||
"imageInsertError": "Αδυναμία εισαγωγής εικόνας. Προσπαθήστε ξανά αργότερα...",
|
||||
"fileTooBig": "Το αρχείο είναι πολύ μεγάλο. Το μέγιστο επιτρεπόμενο μέγεθος είναι {{maxSize}}.",
|
||||
"svgImageInsertError": "Αδυναμία εισαγωγής εικόνας SVG. Η σήμανση της SVG δεν φαίνεται έγκυρη.",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "Μη έγκυρο SVG.",
|
||||
"cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
|
||||
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης"
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Επιλογή",
|
||||
@@ -215,8 +212,8 @@
|
||||
"library": "Βιβλιοθήκη",
|
||||
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
|
||||
"penMode": "",
|
||||
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
|
||||
"eraser": "Γόμα"
|
||||
"link": "",
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Ενέργειες καμβά",
|
||||
@@ -233,16 +230,16 @@
|
||||
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
|
||||
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
|
||||
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
|
||||
"resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
|
||||
"resizeImage": "",
|
||||
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
|
||||
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
|
||||
"lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
|
||||
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
|
||||
"placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
|
||||
"bindTextToElement": "Πατήστε Enter για προσθήκη κειμένου",
|
||||
"deepBoxSelect": "Κρατήστε πατημένο το CtrlOrCmd για να επιλέξετε βαθιά, και να αποτρέψετε τη μεταφορά",
|
||||
"eraserRevert": "Κρατήστε πατημένο το Alt για να επαναφέρετε τα στοιχεία που σημειώθηκαν για διαγραφή"
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
|
||||
@@ -270,39 +267,39 @@
|
||||
"desc_inProgressIntro": "Η ζωντανή συνεργασία με άλλους είναι σε ενεργή.",
|
||||
"desc_shareLink": "Μοιραστείτε τον σύνδεσμο με όποιον θέλετε να δουλέψετε μαζί:",
|
||||
"desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση.",
|
||||
"shareTitle": "Συμμετάσχετε σε μια ζωντανή συνεδρία συνεργασίας για το Excalidraw"
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Σφάλμα"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Αποθήκευση στο δίσκο",
|
||||
"disk_details": "Εξαγωγή δεδομένων σκηνής σε ένα αρχείο από το οποίο μπορείτε να εισάγετε αργότερα.",
|
||||
"disk_details": "",
|
||||
"disk_button": "Αποθήκευση σε αρχείο",
|
||||
"link_title": "Κοινόχρηστος σύνδεσμος",
|
||||
"link_details": "Εξαγωγή ως σύνδεσμο μόνο για ανάγνωση.",
|
||||
"link_button": "Εξαγωγή σε Σύνδεση",
|
||||
"excalidrawplus_description": "Αποθηκεύστε τη σκηνή στο χώρο εργασίας σας Excalidraw+.",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "Εξαγωγή",
|
||||
"excalidrawplus_exportError": "Δεν ήταν δυνατή η εξαγωγή στο Excalidraw+ αυτή τη στιγμή..."
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "Διαβάστε το Blog μας",
|
||||
"click": "κλικ",
|
||||
"deepSelect": "Βαθιά επιλογή",
|
||||
"deepBoxSelect": "Βαθιά επιλογή μέσα στο πλαίσιο και αποτροπή συρσίματος",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "Κυρτό βέλος",
|
||||
"curvedLine": "Κυρτή γραμμή",
|
||||
"documentation": "Εγχειρίδιο",
|
||||
"doubleClick": "διπλό κλικ",
|
||||
"drag": "σύρε",
|
||||
"editor": "Επεξεργαστής",
|
||||
"editSelectedShape": "Επεξεργασία επιλεγμένου σχήματος (κείμενο/βέλος/γραμμή)",
|
||||
"editSelectedShape": "",
|
||||
"github": "Βρήκατε πρόβλημα; Υποβάλετε το",
|
||||
"howto": "Ακολουθήστε τους οδηγούς μας",
|
||||
"or": "ή",
|
||||
"preventBinding": "Αποτροπή δέσμευσης βέλων",
|
||||
"tools": "Εργαλεία",
|
||||
"tools": "",
|
||||
"shortcuts": "Συντομεύσεις πληκτρολογίου",
|
||||
"textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)",
|
||||
"textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)",
|
||||
@@ -310,54 +307,54 @@
|
||||
"view": "Προβολή",
|
||||
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
|
||||
"zoomToSelection": "Ζουμ στην επιλογή",
|
||||
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής"
|
||||
"toggleElementLock": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Καθαρισμός καμβά"
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Δημοσίευση βιβλιοθήκης",
|
||||
"itemName": "Όνομα αντικειμένου",
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"authorName": "Όνομα δημιουργού",
|
||||
"githubUsername": "GitHub username",
|
||||
"twitterUsername": "Twitter username",
|
||||
"libraryName": "Όνομα βιβλιοθήκης",
|
||||
"libraryDesc": "Περιγραφή βιβλιοθήκης",
|
||||
"libraryDesc": "",
|
||||
"website": "Ιστοσελίδα",
|
||||
"placeholder": {
|
||||
"authorName": "Όνομα ή όνομα χρήστη",
|
||||
"libraryName": "Όνομα της βιβλιοθήκης σας",
|
||||
"libraryDesc": "Περιγραφή της βιβλιοθήκης σας ώστε να βοηθήσει το κοινό να κατανοήσει τη χρήση της",
|
||||
"githubHandle": "Όνομα χρήστη στο GitHub (προαιρετικό), ώστε να μπορείτε να επεξεργαστείτε τη βιβλιοθήκη αφού υποβληθεί για αξιολόγηση",
|
||||
"twitterHandle": "Όνομα χρήστη Twitter (προαιρετικό), ώστε να γνωρίζουμε σε ποιον/η να δώσουμε εύσημα κατά την προώθηση μέσω Twitter",
|
||||
"website": "Σύνδεσμος για την προσωπική σας ιστοσελίδα ή αλλού (προαιρετικό)"
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "Απαιτείται",
|
||||
"website": "Εισάγετε μια έγκυρη διεύθυνση URL"
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "Υποβάλετε τη βιβλιοθήκη σας για να συμπεριληφθεί στο ",
|
||||
"link": "δημόσιο αποθετήριο βιβλιοθήκης",
|
||||
"post": "ώστε να χρησιμοποιηθεί από άλλα άτομα στα σχέδιά τους."
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "Η βιβλιοθήκη πρέπει πρώτα να εγκριθεί χειροκίνητα. Παρακαλώ διαβάστε τους ",
|
||||
"pre": "",
|
||||
"link": "οδηγίες",
|
||||
"post": " πριν την υποβολή. Θα χρειαστείτε έναν λογαριασμό GitHub για την επικοινωνία και για να προβείτε σε αλλαγές εφ' όσον χρειαστεί, αλλά δεν είναι αυστηρή απαίτηση."
|
||||
"post": ""
|
||||
},
|
||||
"noteLicense": {
|
||||
"pre": "Με την υποβολή, συμφωνείτε ότι η βιβλιοθήκη θα δημοσιευθεί υπό την ",
|
||||
"link": "Άδεια MIT, ",
|
||||
"post": "που εν συντομία σημαίνει ότι ο καθένας μπορεί να τα χρησιμοποιήσει χωρίς περιορισμούς."
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteItems": "Κάθε αντικείμενο της βιβλιοθήκης πρέπει να έχει το δικό του όνομα ώστε να μπορεί να φιλτραριστεί. Θα συμπεριληφθούν τα ακόλουθα αντικείμενα βιβλιοθήκης:",
|
||||
"atleastOneLibItem": "Παρακαλώ επιλέξτε τουλάχιστον ένα αντικείμενο βιβλιοθήκης για να ξεκινήσετε",
|
||||
"republishWarning": "Σημείωση: μερικά από τα επιλεγμένα αντικέιμενα έχουν ήδη επισημανθεί ως δημοσιευμένα/υποβεβλημένα. Θα πρέπει να υποβάλετε αντικείμενα εκ νέου μόνο για να ενημερώσετε μία ήδη υπάρχουσα βιβλιοθήκη ή υποβολή."
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Η βιβλιοθήκη υποβλήθηκε",
|
||||
"content": "Ευχαριστούμε {{authorName}}. Η βιβλιοθήκη σας έχει υποβληθεί για αξιολόγηση. Μπορείτε να παρακολουθείτε τη διαδικασία",
|
||||
"title": "",
|
||||
"content": "",
|
||||
"link": "εδώ"
|
||||
},
|
||||
"confirmDialog": {
|
||||
|
||||
+2
-6
@@ -114,11 +114,6 @@
|
||||
"create": "Create link",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Edit line",
|
||||
"exit": "Exit line editor"
|
||||
},
|
||||
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@@ -192,7 +187,8 @@
|
||||
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
|
||||
"resetLibrary": "This will clear your library. Are you sure?",
|
||||
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
|
||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
|
||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
|
||||
"browserZoom": "Your browser's zoom level is not set to 100% which may cause the board to display incorrectly"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Unsupported file type.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Crear enlace",
|
||||
"label": "Enlace"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
@@ -128,7 +124,7 @@
|
||||
"sidebarLock": "Mantener barra lateral abierta"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "No hay elementos añadidos todavía...",
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "Seleccione un elemento en el lienzo para añadirlo aquí, o instale una biblioteca del repositorio público, a continuación.",
|
||||
"hint_emptyPrivateLibrary": "Seleccione un elemento del lienzo para añadirlo aquí."
|
||||
},
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
|
||||
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
|
||||
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.",
|
||||
"browserZoom": "El nivel de zoom de tu navegador no está configurado al 100%, lo que puede causar que el tablero se muestre de manera incorrecta"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo de archivo no admitido.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Sortu esteka",
|
||||
"label": "Esteka"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Blokeatu",
|
||||
"unlock": "Desblokeatu",
|
||||
@@ -125,12 +121,12 @@
|
||||
"unlockAll": "Desblokeatu guztiak"
|
||||
},
|
||||
"statusPublished": "Argitaratua",
|
||||
"sidebarLock": "Mantendu alboko barra irekita"
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Oraindik ez da elementurik gehitu...",
|
||||
"hint_emptyLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko, edo instalatu liburutegi bat beheko biltegi publikotik.",
|
||||
"hint_emptyPrivateLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko."
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Garbitu oihala",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
|
||||
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
|
||||
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago."
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Onartu gabeko fitxategi mota.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "ایجاد پیوند",
|
||||
"label": "لینک"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "قفل",
|
||||
"unlock": "باز کردن",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
|
||||
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است."
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع فایل پشتیبانی نشده.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Luo linkki",
|
||||
"label": "Linkki"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
|
||||
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
|
||||
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tiedostotyyppiä ei tueta.",
|
||||
|
||||
+33
-36
@@ -11,28 +11,28 @@
|
||||
"copyAsSvg": "Copier dans le presse-papier en SVG",
|
||||
"copyText": "Copier dans le presse-papier en tant que texte",
|
||||
"bringForward": "Envoyer vers l'avant",
|
||||
"sendToBack": "Déplacer à l'arrière-plan",
|
||||
"sendToBack": "Mettre en arrière-plan",
|
||||
"bringToFront": "Mettre au premier plan",
|
||||
"sendBackward": "Reculer d'un plan",
|
||||
"sendBackward": "Envoyer vers l'arrière",
|
||||
"delete": "Supprimer",
|
||||
"copyStyles": "Copier les styles",
|
||||
"pasteStyles": "Coller les styles",
|
||||
"stroke": "Trait",
|
||||
"background": "Arrière-plan",
|
||||
"fill": "Remplissage",
|
||||
"strokeWidth": "Largeur du contour",
|
||||
"strokeWidth": "Largeur du trait",
|
||||
"strokeStyle": "Style du trait",
|
||||
"strokeStyle_solid": "Continu",
|
||||
"strokeStyle_solid": "Plein",
|
||||
"strokeStyle_dashed": "Tirets",
|
||||
"strokeStyle_dotted": "Pointillés",
|
||||
"strokeStyle_dotted": "Pointillé",
|
||||
"sloppiness": "Style de tracé",
|
||||
"opacity": "Transparence",
|
||||
"opacity": "Opacité",
|
||||
"textAlign": "Alignement du texte",
|
||||
"edges": "Angles",
|
||||
"sharp": "Pointus",
|
||||
"round": "Arrondis",
|
||||
"arrowheads": "Extrémités",
|
||||
"arrowhead_none": "Sans",
|
||||
"arrowheads": "Extrémités de flèche",
|
||||
"arrowhead_none": "Aucune",
|
||||
"arrowhead_arrow": "Flèche",
|
||||
"arrowhead_bar": "Barre",
|
||||
"arrowhead_dot": "Point",
|
||||
@@ -43,23 +43,23 @@
|
||||
"withBackground": "Arrière-plan",
|
||||
"exportEmbedScene": "Intégrer la scène",
|
||||
"exportEmbedScene_details": "Les données de scène seront enregistrées dans le fichier PNG/SVG exporté, afin que la scène puisse être restaurée à partir de celui-ci.\nCela augmentera la taille du fichier exporté.",
|
||||
"addWatermark": "Ajouter \"Réalisé avec Excalidraw\"",
|
||||
"addWatermark": "Ajouter \"Fait avec Excalidraw\"",
|
||||
"handDrawn": "À la main",
|
||||
"normal": "Normale",
|
||||
"code": "Code",
|
||||
"small": "Petite",
|
||||
"medium": "Moyenne",
|
||||
"large": "Grande",
|
||||
"veryLarge": "Très grande",
|
||||
"small": "Petit",
|
||||
"medium": "Moyen",
|
||||
"large": "Grand",
|
||||
"veryLarge": "Très grand",
|
||||
"solid": "Solide",
|
||||
"hachure": "Hachures",
|
||||
"crossHatch": "Hachures croisées",
|
||||
"thin": "Fine",
|
||||
"bold": "Épaisse",
|
||||
"left": "À gauche",
|
||||
"center": "Au centre",
|
||||
"right": "À droite",
|
||||
"extraBold": "Très épaisse",
|
||||
"hachure": "Hachure",
|
||||
"crossHatch": "Hachure croisée",
|
||||
"thin": "Fin",
|
||||
"bold": "Épais",
|
||||
"left": "Gauche",
|
||||
"center": "Centre",
|
||||
"right": "Droite",
|
||||
"extraBold": "Très épais",
|
||||
"architect": "Architecte",
|
||||
"artist": "Artiste",
|
||||
"cartoonist": "Caricaturiste",
|
||||
@@ -68,7 +68,7 @@
|
||||
"canvasColors": "Utilisé sur la zone de dessin",
|
||||
"canvasBackground": "Arrière-plan du canevas",
|
||||
"drawingCanvas": "Zone de dessin",
|
||||
"layers": "Disposition",
|
||||
"layers": "Calques",
|
||||
"actions": "Actions",
|
||||
"language": "Langue",
|
||||
"liveCollaboration": "Collaboration en direct",
|
||||
@@ -86,27 +86,27 @@
|
||||
"libraryLoadingMessage": "Chargement de la bibliothèque…",
|
||||
"libraries": "Parcourir les bibliothèques",
|
||||
"loadingScene": "Chargement de la scène…",
|
||||
"align": "Alignement",
|
||||
"align": "Aligner",
|
||||
"alignTop": "Aligner en haut",
|
||||
"alignBottom": "Aligner en bas",
|
||||
"alignLeft": "Aligner à gauche",
|
||||
"alignRight": "Aligner à droite",
|
||||
"centerVertically": "Centrer verticalement",
|
||||
"centerHorizontally": "Centrer horizontalement",
|
||||
"distributeHorizontally": "Répartir horizontalement",
|
||||
"distributeVertically": "Répartir verticalement",
|
||||
"distributeHorizontally": "Distribuer horizontalement",
|
||||
"distributeVertically": "Distribuer verticalement",
|
||||
"flipHorizontal": "Retourner horizontalement",
|
||||
"flipVertical": "Retourner verticalement",
|
||||
"viewMode": "Mode présentation",
|
||||
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
|
||||
"share": "Partager",
|
||||
"showStroke": "Afficher le sélecteur de couleur de trait",
|
||||
"showBackground": "Afficher le sélecteur de couleur de fond",
|
||||
"showBackground": "Afficher le sélecteur de couleur d'arrière-plan",
|
||||
"toggleTheme": "Changer le thème",
|
||||
"personalLib": "Bibliothèque personnelle",
|
||||
"excalidrawLib": "Bibliothèque Excalidraw",
|
||||
"decreaseFontSize": "Diminuer la taille de police",
|
||||
"increaseFontSize": "Augmenter la taille de la police",
|
||||
"increaseFontSize": "Augmenter la taille de police",
|
||||
"unbindText": "Dissocier le texte",
|
||||
"bindText": "Associer le texte au conteneur",
|
||||
"link": {
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Ajouter un lien",
|
||||
"label": "Lien"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Modifier la ligne",
|
||||
"exit": "Quitter l'éditeur de ligne"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Verrouiller",
|
||||
"unlock": "Déverrouiller",
|
||||
@@ -174,9 +170,9 @@
|
||||
"alerts": {
|
||||
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
|
||||
"couldNotCreateShareableLink": "Impossible de créer un lien de partage.",
|
||||
"couldNotCreateShareableLinkTooBig": "Impossible de créer un lien de partage : la scène est trop volumineuse",
|
||||
"couldNotCreateShareableLinkTooBig": "Impossible de créer un lien partageable : la scène est trop volumineuse",
|
||||
"couldNotLoadInvalidFile": "Impossible de charger un fichier invalide",
|
||||
"importBackendFailed": "L'importation depuis le serveur a échoué.",
|
||||
"importBackendFailed": "L'importation depuis le backend a échoué.",
|
||||
"cannotExportEmptyCanvas": "Impossible d'exporter un canevas vide.",
|
||||
"couldNotCopyToClipboard": "Impossible de copier dans le presse-papiers.",
|
||||
"decryptFailed": "Les données n'ont pas pu être déchiffrées.",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
|
||||
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
|
||||
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée.",
|
||||
"browserZoom": "Le niveau de zoom de votre navigateur n'est pas défini sur 100 %, ce qui peut entraîner un affichage incorrect du tableau"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Type de fichier non supporté.",
|
||||
@@ -232,7 +229,7 @@
|
||||
"text_editing": "Appuyez sur ÉCHAP ou Ctrl/Cmd+ENTRÉE pour terminer l'édition",
|
||||
"linearElementMulti": "Cliquez sur le dernier point ou appuyez sur Échap ou Entrée pour terminer",
|
||||
"lockAngle": "Vous pouvez restreindre l'angle en maintenant MAJ",
|
||||
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement, maintenez la touche ALT pour redimensionner par rapport au centre",
|
||||
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement,\nmaintenez la touche ALT pour redimensionner par rapport au centre",
|
||||
"resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre",
|
||||
"rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation",
|
||||
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
|
||||
@@ -241,7 +238,7 @@
|
||||
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
|
||||
"publishLibrary": "Publier votre propre bibliothèque",
|
||||
"bindTextToElement": "Appuyer sur Entrée pour ajouter du texte",
|
||||
"deepBoxSelect": "Maintenir Ctrl ou Cmd pour sélectionner dans les groupes et empêcher le déplacement",
|
||||
"deepBoxSelect": "Maintenir CtrlOuCmd pour sélectionner dans les groupes, et empêcher le déplacement",
|
||||
"eraserRevert": "Maintenez Alt enfoncé pour annuler les éléments marqués pour suppression"
|
||||
},
|
||||
"canvasError": {
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Crear ligazón",
|
||||
"label": "Ligazón"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "יצירת קישור",
|
||||
"label": "קישור"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "נעילה",
|
||||
"unlock": "ביטול נעילה",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
|
||||
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
|
||||
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל."
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "סוג הקובץ אינו נתמך.",
|
||||
|
||||
+31
-34
@@ -71,7 +71,7 @@
|
||||
"layers": "परतें",
|
||||
"actions": "कार्रवाई",
|
||||
"language": "भाषा",
|
||||
"liveCollaboration": "जीवंत सहयोग",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "डुप्लिकेट",
|
||||
"untitled": "अशीर्षित",
|
||||
"name": "नाम",
|
||||
@@ -101,7 +101,7 @@
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "शेयर करें",
|
||||
"showStroke": "",
|
||||
"showBackground": "पृष्ठभूमि रंग वरक़ दिखाये",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "रेखा संपादित करे",
|
||||
"exit": "रेखा संपादक के बाहर"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "ताले में रखें",
|
||||
"unlock": "ताले से बाहर",
|
||||
@@ -165,11 +161,11 @@
|
||||
"zenMode": "ज़ेन मोड",
|
||||
"exitZenMode": "जेन मोड से बाहर निकलें",
|
||||
"cancel": "",
|
||||
"clear": "साफ़ करे",
|
||||
"remove": "हटाएं",
|
||||
"publishLibrary": "प्रकाशित करें",
|
||||
"submit": "प्रस्तुत करे",
|
||||
"confirm": "पुष्टि करें"
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "इससे पूरा कैनवास साफ हो जाएगा। क्या आपको यकीन है?",
|
||||
@@ -178,39 +174,40 @@
|
||||
"couldNotLoadInvalidFile": "अमान्य फ़ाइल लोड नहीं की जा सकी",
|
||||
"importBackendFailed": "बैकएंड से आयात करना विफल रहा।",
|
||||
"cannotExportEmptyCanvas": "खाली कैनवास निर्यात नहीं कर सकता।",
|
||||
"couldNotCopyToClipboard": "क्लिपबोर्ड पर कॉपी नहीं किया जा सका",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "डेटा को डिक्रिप्ट नहीं किया जा सका।",
|
||||
"uploadedSecurly": "अपलोड को एंड-टू-एंड एन्क्रिप्शन के साथ सुरक्षित किया गया है, जिसका मतलब है कि एक्सक्लूसिव सर्वर और थर्ड पार्टी कंटेंट नहीं पढ़ सकते हैं।",
|
||||
"loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?",
|
||||
"collabStopOverridePrompt": "चालू सत्र समाप्ति से आपका संग्रहित पूर्व स्थानीय अधिलेखन नष्ट होकर पुनः अधिलेखित होगा, क्या आपको यक़ीन हैं? ( यदी आपको पूर्व स्थापित अधिलेखन सुरक्षित चाहिये तो बस ब्राउज़र टैब बंद करे)",
|
||||
"errorAddingToLibrary": "संग्रह में जोडा न जा सका",
|
||||
"errorRemovingFromLibrary": "संग्रह से हटाया नहीं जा सका",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्टि करें आकार संख्या",
|
||||
"imageDoesNotContainScene": "ऐसा लगता है कि इस छवि में कोई दृश्य डेटा नहीं है। क्या आपने निर्यात के दौरान दृश्य एम्बेडिंग अनुमतित की है?",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
|
||||
"invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।",
|
||||
"resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?",
|
||||
"removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?",
|
||||
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं"
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": "आपके ब्राउज़र का ज़ूम लेवल 100% नहीं हैं इस कारण दृष्य पटल ग़लत दिख सकता हैं"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "असमर्थित फाइल प्रकार",
|
||||
"imageInsertError": "छवि सम्मिलित नहीं की जा सकी. पुनः प्रयत्न करे...",
|
||||
"fileTooBig": "फ़ाइल ज़रूरत से ज़्यादा बड़ी हैं. अधिकतम अनुमित परिमाण {{maxSize}} हैं",
|
||||
"svgImageInsertError": "एसवीजी छवि सम्मिलित नहीं कर सके, एसवीजी रचना अनुचित हैं",
|
||||
"invalidSVGString": "अनुचित SVG",
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "कॉलेब सर्वर से कनेक्शन नहीं हो पा रहा. कृपया पृष्ठ को पुनः लाने का प्रयास करे.",
|
||||
"importLibraryError": "संग्रह प्रतिष्ठापित नहीं किया जा सका"
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "चयन",
|
||||
"image": "छवि सम्मिलित करें",
|
||||
"image": "",
|
||||
"rectangle": "आयात",
|
||||
"diamond": "तिर्यग्वर्ग",
|
||||
"ellipse": "दीर्घवृत्त",
|
||||
"arrow": "तीर",
|
||||
"line": "रेखा",
|
||||
"freedraw": "चित्रांतित करे",
|
||||
"freedraw": "",
|
||||
"text": "पाठ",
|
||||
"library": "लाइब्रेरी",
|
||||
"lock": "ड्राइंग के बाद चयनित टूल को सक्रिय रखें",
|
||||
@@ -334,16 +331,16 @@
|
||||
},
|
||||
"errors": {
|
||||
"required": "",
|
||||
"website": "मान्य URL प्रविष्ट करें"
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "संग्रह सम्मिलित करने हेतु प्रस्तुत करें ",
|
||||
"link": "सार्वजनिक संग्रहालय",
|
||||
"post": "अन्य वक्तियों को उनके चित्रकारी में उपयोग के लिये"
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "संग्रह को पहले स्वीकृति आवश्यक कृपया यह पढ़ें ",
|
||||
"link": "दिशा-निर्देश",
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteLicense": {
|
||||
@@ -353,7 +350,7 @@
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": "टिप्पणी: कुछ चुने हुवे आइटम पहले ही प्रकाशित/प्रस्तुत किए जा चुके हैं। किसी प्रकाशित संग्रह को अद्यतन करते समय या पहले से प्रस्तुत आइटम को पुन्हा प्रस्तुत करते समय, आप बस उसे केवल अद्यतन करें ।"
|
||||
"republishWarning": "टिप्पणी: कुछ चुने हुवे आइटम पहले ही प्रकाशित/प्रस्तुत किए जा चुके हैं। किसी प्रकाशित संग्रह को अद्यतन करते समय या प्रस्तुतित आइटम को पुन्हा प्रस्तुत करते समय, आप बस उसे केवल अद्यतन करें ।"
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Hivatkozás létrehozása",
|
||||
"label": "Hivatkozás"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.",
|
||||
"resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
|
||||
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?",
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva."
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva.",
|
||||
"browserZoom": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nem támogatott fájltípus.",
|
||||
|
||||
@@ -114,10 +114,6 @@
|
||||
"create": "Buat tautan",
|
||||
"label": "Tautan"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Edit tautan",
|
||||
"exit": "Keluar editor garis"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Kunci",
|
||||
"unlock": "Lepas",
|
||||
@@ -191,7 +187,8 @@
|
||||
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
|
||||
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
|
||||
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.",
|
||||
"browserZoom": "Pembesaran peramban Anda tidak 100% yang mana dapat menyebabkan layar tidak menampilkan dengan benar"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipe file tidak didukung.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user