Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle fa9631617f wip: get exact bounding box from bitmap when exporting w/o padding 2022-11-03 18:34:17 +01:00
dwelle 0314e81396 feat: support toggling export padding 2022-11-03 17:31:41 +01:00
191 changed files with 9373 additions and 14914 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
*
!.env.development
!.env.production
!.env
!.eslintrc.json
!.npmrc
!.prettierrc
-2
View File
@@ -20,5 +20,3 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto release excalidraw next
on:
push:
branches:
- release
- master
jobs:
Auto-release-excalidraw-next:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Build Docker image
on:
push:
branches:
- release
- master
jobs:
build-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Cancel previous runs
on:
push:
branches:
- release
- master
pull_request:
jobs:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish Docker
on:
push:
branches:
- release
- master
jobs:
publish-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: New Sentry production release
on:
push:
branches:
- release
- master
jobs:
sentry:
+3 -3
View File
@@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
version "2.0.2"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==
dependencies:
big.js "^5.2.2"
emojis-list "^3.0.0"
+6 -20
View File
@@ -31,7 +31,6 @@
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
@@ -51,23 +50,11 @@
"pwacompat": "2.0.17",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-scripts": "5.0.1",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
"typescript": "4.5.5"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.0",
@@ -80,7 +67,6 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.4.0",
"lint-staged": "12.3.7",
@@ -103,10 +89,11 @@
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"build:prebuild": "node ./scripts/prebuild.js",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
@@ -116,7 +103,6 @@
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
+1 -2
View File
@@ -146,8 +146,7 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
+81
View File
@@ -0,0 +1,81 @@
// eslint-disable-next-line no-restricted-globals
// eslint-disable-next-line no-unused-expressions
/* eslint-disable no-restricted-globals */
/* global importScripts, workbox */
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
// in dev, `process` is undefined because this file is not compiled until build
const IS_DEVELOPMENT =
typeof process === "undefined" || process.env.NODE_ENV !== "production";
if (IS_DEVELOPMENT) {
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.setConfig({
debug: true,
});
} else {
importScripts("/workbox/workbox-sw.js");
workbox.setConfig({
modulePathPrefix: "/workbox/",
});
}
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
if (!IS_DEVELOPMENT) {
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute(
workbox.precaching.getCacheKeyForURL("./index.html"),
{
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
},
);
}
// Cache relevant font files
workbox.routing.registerRoute(
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
new workbox.strategies.StaleWhileRevalidate({
cacheName: "fonts",
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
}),
);
self.addEventListener("fetch", (event) => {
if (
event.request.method === "POST" &&
event.request.url.endsWith("/web-share-target")
) {
return event.respondWith(
(async () => {
const formData = await event.request.formData();
const file = formData.get("file");
const webShareTargetCache = await caches.open("web-share-target");
await webShareTargetCache.put("shared-file", new Response(file));
return Response.redirect("/?web-share-target", 303);
})(),
);
}
});
+2 -4
View File
@@ -50,8 +50,8 @@ const crowdinMap = {
"lv-LV": "en-lv",
"cs-CZ": "en-cs",
"kk-KZ": "en-kk",
"vi-VN": "en-vi",
"mr-IN": "en-mr",
"vi-vn": "en-vi",
"mr-in": "en-mr",
};
const flags = {
@@ -120,7 +120,6 @@ const languages = {
"fa-IR": "فارسی",
"fi-FI": "Suomi",
"fr-FR": "Français",
"gl-ES": "Galego",
"he-IL": "עברית",
"hi-IN": "हिन्दी",
"hu-HU": "Magyar",
@@ -130,7 +129,6 @@ const languages = {
"kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі",
"ko-KR": "한국어",
"ku-TR": "Kurdî",
"lt-LT": "Lietuvių",
"lv-LV": "Latviešu",
"my-MM": "Burmese",
+21
View File
@@ -0,0 +1,21 @@
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");
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();
-12
View File
@@ -6,10 +6,6 @@ import {
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
} from "../element/textWysiwyg";
import {
hasBoundTextElement,
isTextBindableContainer,
@@ -42,11 +38,6 @@ export const actionUnbindText = register({
boundTextElement.originalText,
getFontString(boundTextElement),
);
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
@@ -58,9 +49,6 @@ export const actionUnbindText = register({
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
height: originalContainerHeight
? originalContainerHeight
: element.height,
});
}
});
-5
View File
@@ -90,7 +90,6 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -127,7 +126,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -164,7 +162,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@@ -274,7 +271,6 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
@@ -286,7 +282,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
-30
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
@@ -24,31 +23,11 @@ export const actionCopy = register({
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
@@ -56,9 +35,6 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
@@ -101,9 +77,6 @@ export const actionCopyAsSvg = register({
};
}
},
contextItemPredicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg",
});
@@ -158,9 +131,6 @@ export const actionCopyAsPng = register({
};
}
},
contextItemPredicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});
+22 -1
View File
@@ -89,6 +89,28 @@ export const actionChangeExportScale = register({
},
});
export const actionChangeExportPadding = register({
name: "changeExportPadding",
trackEvent: { category: "export", action: "togglePadding" },
perform: (_elements, appState, value) => {
return {
appState: {
...appState,
exportPadding: value ? DEFAULT_EXPORT_PADDING : 0,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<CheckboxItem
checked={!!appState.exportPadding}
onChange={(checked) => updateData(checked)}
>
{"Padding"}
</CheckboxItem>
),
});
export const actionChangeExportBackground = register({
name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
@@ -179,7 +201,6 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try {
+11 -5
View File
@@ -14,7 +14,6 @@ import {
} from "../element/bounds";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@@ -64,8 +63,7 @@ export const actionFlipVertical = register({
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
keyTest: (event) => event.shiftKey && event.code === "KeyV",
contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
@@ -153,7 +151,11 @@ const flipElement = (
let initialPointsCoords;
if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords(element, element.points);
initialPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
}
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
@@ -211,7 +213,11 @@ const flipElement = (
// 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);
const finalPointsCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness,
);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
-2
View File
@@ -56,7 +56,6 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({
name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
@@ -74,7 +73,6 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") {
-1
View File
@@ -6,7 +6,6 @@ import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
+58 -64
View File
@@ -42,7 +42,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
VERTICAL_ALIGN,
} from "../constants";
import {
@@ -58,7 +57,7 @@ import {
import {
isBoundToContainer,
isLinearElement,
isUsingAdaptiveRadius,
isLinearElementType,
} from "../element/typeChecks";
import {
Arrowhead,
@@ -73,7 +72,7 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeRoundness,
canChangeSharpness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
@@ -817,19 +816,16 @@ export const actionChangeVerticalAlign = register({
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
},
]}
value={getFormValue(elements, appState, (element) => {
@@ -849,71 +845,69 @@ export const actionChangeVerticalAlign = register({
},
});
export const actionChangeRoundness = register({
name: "changeRoundness",
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
type: isUsingAdaptiveRadius(el.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
appState: {
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasLegacyRoundness = targetElements.some(
(el) => el.roundness?.type === ROUNDNESS.LEGACY,
);
return (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.activeTool.type);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeSharpness: value,
}),
),
appState: {
...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: EdgeSharpIcon,
},
{
value: "round",
text: t("labels.round"),
icon: EdgeRoundIcon,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeArrowhead = register({
+1 -13
View File
@@ -13,11 +13,7 @@ import {
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { hasBoundTextElement } from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests.
@@ -81,14 +77,6 @@ export const actionPasteStyles = register({
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
});
if (isTextElement(newElement)) {
-4
View File
@@ -5,7 +5,6 @@ import { AppState } from "../types";
export const actionToggleGridMode = register({
name: "gridMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
@@ -20,9 +19,6 @@ export const actionToggleGridMode = register({
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemPredicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});
+9 -3
View File
@@ -41,9 +41,15 @@ export const actionToggleLock = register({
: "labels.elementLock.lock";
}
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (
-1
View File
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
viewMode: true,
trackEvent: { category: "menu" },
perform(elements, appState) {
return {
-4
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleViewMode = register({
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
-4
View File
@@ -3,7 +3,6 @@ import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
@@ -18,9 +17,6 @@ export const actionToggleZenMode = register({
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
+6 -2
View File
@@ -9,6 +9,7 @@ import {
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics";
const trackAction = (
@@ -102,8 +103,11 @@ export class ActionManager {
const action = data[0];
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
return false;
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
const elements = this.getElementsIncludingDeleted();
+1 -1
View File
@@ -48,7 +48,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Delete")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
+2 -6
View File
@@ -68,6 +68,7 @@ export type ActionName =
| "finalize"
| "changeProjectName"
| "changeExportBackground"
| "changeExportPadding"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
@@ -91,7 +92,7 @@ export type ActionName =
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeRoundness"
| "changeSharpness"
| "alignTop"
| "alignBottom"
| "alignLeft"
@@ -143,8 +144,6 @@ export interface Action {
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:
@@ -166,7 +165,4 @@ export interface Action {
value: any,
) => boolean;
};
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
}
+7 -4
View File
@@ -1,5 +1,6 @@
import oc from "open-color";
import {
DEFAULT_EXPORT_PADDING,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
@@ -28,11 +29,12 @@ export const getDefaultAppState = (): Omit<
currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100,
currentItemRoughness: 1,
currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black,
currentItemRoundness: "round",
currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -54,6 +56,7 @@ export const getDefaultAppState = (): Omit<
exportScale: defaultExportScale,
exportEmbedScene: false,
exportWithDarkMode: false,
exportPadding: DEFAULT_EXPORT_PADDING,
fileHandle: null,
gridSize: null,
isBindingEnabled: true,
@@ -64,7 +67,6 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse",
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null,
openPopup: null,
openSidebar: null,
@@ -120,7 +122,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemRoundness: {
currentItemLinearStrokeSharpness: {
browser: true,
export: false,
server: false,
@@ -129,6 +131,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
@@ -144,6 +147,7 @@ const APP_STATE_STORAGE_CONF = (<
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportPadding: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
@@ -158,7 +162,6 @@ const APP_STATE_STORAGE_CONF = (<
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
contextMenu: { 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 },
+2 -2
View File
@@ -172,7 +172,7 @@ const commonProps = {
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
roundness: null,
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
@@ -322,7 +322,7 @@ const chartBaseElements = (
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
roundness: null,
strokeSharpness: "sharp",
strokeStyle: "solid",
textAlign: "center",
})
-27
View File
@@ -1,27 +0,0 @@
import { parseClipboard } from "./clipboard";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
});
});
+14 -26
View File
@@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = (
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
export const getSystemClipboard = async (
const getSystemClipboard = async (
event: ClipboardEvent | null,
): Promise<string> => {
try {
const text = event
? event.clipboardData?.getData("text/plain")
? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return (text || "").trim();
return text || "";
} catch {
return "";
}
@@ -129,25 +129,19 @@ export const getSystemClipboard = async (
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
return getAppClipboard();
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
@@ -160,23 +154,17 @@ export const parseClipboard = async (
return {
elements: systemClipboardData.elements,
files: systemClipboardData.files,
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
};
}
} catch (e) {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard };
return appClipboardData;
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? appClipboardData
: { text: systemClipboard };
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
+14 -12
View File
@@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import {
canChangeRoundness,
canChangeSharpness,
canHaveArrowheads,
getTargetElements,
hasBackground,
@@ -25,12 +25,11 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement";
export const SelectedShapeActions = ({
appState,
@@ -110,9 +109,9 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<>{renderAction("changeRoundness")}</>
{(canChangeSharpness(appState.activeTool.type) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(appState.activeTool.type) ||
@@ -126,8 +125,10 @@ export const SelectedShapeActions = ({
</>
)}
{shouldAllowVerticalAlign(targetElements) &&
renderAction("changeVerticalAlign")}
{targetElements.some(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
@@ -217,12 +218,13 @@ export const ShapesSwitcher = ({
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
{SHAPES.map(({ value, icon, key, fillable }, index) => {
const numberKey = value === "eraser" ? 0 : index + 1;
const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}`
: `${numberKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
@@ -232,7 +234,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey}
keyBindingLabel={`${numberKey}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
+389 -483
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -4,8 +4,8 @@
.Avatar {
width: 1.25rem;
height: 1.25rem;
position: relative;
border-radius: 100%;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px;
display: flex;
justify-content: center;
@@ -21,16 +21,5 @@
height: 100%;
border-radius: 100%;
}
&::before {
content: "";
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
border: 1px solid var(--avatar-border-color);
border-radius: 100%;
}
}
}
+3 -6
View File
@@ -66,13 +66,10 @@ const getColor = (color: string): string | null => {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
return isValidColor(color)
? color
: isValidColor(`#${color}`)
? `#${color}`
: null;
};
+11 -11
View File
@@ -19,7 +19,7 @@
color: var(--popup-text-color);
}
.context-menu-item {
.context-menu-option {
position: relative;
width: 100%;
min-width: 9.5rem;
@@ -43,16 +43,16 @@
}
&.dangerous {
.context-menu-item__label {
.context-menu-option__label {
color: $oc-red-7;
}
}
.context-menu-item__label {
.context-menu-option__label {
justify-self: start;
margin-inline-end: 20px;
}
.context-menu-item__shortcut {
.context-menu-option__shortcut {
justify-self: end;
opacity: 0.6;
font-family: inherit;
@@ -60,37 +60,37 @@
}
}
.context-menu-item:hover {
.context-menu-option:hover {
color: var(--popup-bg-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-item__label {
.context-menu-option__label {
color: var(--popup-bg-color);
}
background-color: $oc-red-6;
}
}
.context-menu-item:focus {
.context-menu-option:focus {
z-index: 1;
}
@include isMobile {
.context-menu-item {
.context-menu-option {
display: block;
.context-menu-item__label {
.context-menu-option__label {
margin-inline-end: 0;
}
.context-menu-item__shortcut {
.context-menu-option__shortcut {
display: none;
}
}
}
.context-menu-item-separator {
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}
+126 -101
View File
@@ -1,3 +1,4 @@
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
@@ -9,116 +10,140 @@ import {
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import {
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
export type ContextMenuOption = "separator" | Action;
type ContextMenuProps = {
actionManager: ActionManager;
items: ContextMenuItems;
options: ContextMenuOption[];
onCloseRequest?(): void;
top: number;
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
if (
item &&
(item === CONTEXT_MENU_SEPARATOR ||
!item.contextItemPredicate ||
item.contextItemPredicate(
elements,
appState,
actionManager.app.props,
actionManager.app,
))
) {
acc.push(item);
}
return acc;
}, []);
return (
<Popover
onCloseRequest={() => setAppState({ contextMenu: null })}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
elements,
}: ContextMenuProps) => {
return (
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{filteredItems.map((item, idx) => {
if (item === CONTEXT_MENU_SEPARATOR) {
if (
!filteredItems[idx - 1] ||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
) {
return null;
}
return <hr key={idx} className="context-menu-item-separator" />;
}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = item.name;
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState));
} else {
label = t(item.contextItemLabel);
}
const actionName = option.name;
let label = "";
if (option.contextItemLabel) {
if (typeof option.contextItemLabel === "function") {
label = t(option.contextItemLabel(elements, appState));
} else {
label = t(option.contextItemLabel);
}
return (
<li
key={idx}
data-testid={actionName}
onClick={() => {
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => {
actionManager.executeAction(item, "contextMenu");
});
}}
}
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
>
<button
className={clsx("context-menu-item", {
dangerous: actionName === "deleteSelectedElements",
checkmark: item.checked?.(appState),
})}
>
<div className="context-menu-item__label">{label}</div>
<kbd className="context-menu-item__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
};
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
return contextMenuNode;
}
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
};
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
push(params: ContextMenuParams) {
const options = Array.of<ContextMenuOption>();
params.options.forEach((option) => {
if (option) {
options.push(option);
}
});
if (options.length) {
render(
<ContextMenu
top={params.top}
left={params.left}
options={options}
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
elements={params.elements}
/>,
getContextMenuNode(params.container),
);
}
},
);
};
@@ -1,8 +1,8 @@
import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n";
import { t } from "../i18n";
import { shield } from "./icons";
import { Tooltip } from "./Tooltip";
export const EncryptedIcon = () => (
const EncryptedIcon = () => (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
@@ -15,3 +15,5 @@ export const EncryptedIcon = () => (
</Tooltip>
</a>
);
export default EncryptedIcon;
@@ -1,37 +1,35 @@
import clsx from "clsx";
import { ActionManager } from "../../actions/manager";
import { t } from "../../i18n";
import { AppState } from "../../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { WelcomeScreenHelpArrow } from "../icons";
import { Section } from "../Section";
import Stack from "../Stack";
import WelcomeScreenDecor from "../WelcomeScreenDecor";
import FooterCenter from "./FooterCenter";
} from "./Actions";
import { useDevice } from "./App";
import { WelcomeScreenHelpArrow } from "./icons";
import { Section } from "./Section";
import Stack from "./Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor";
const Footer = ({
appState,
actionManager,
renderCustomFooter,
showExitZenModeBtn,
renderWelcomeScreen,
children,
}: {
appState: AppState;
actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean;
children?: React.ReactNode;
}) => {
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
@@ -71,7 +69,17 @@ const Footer = ({
</Section>
</Stack.Col>
</div>
<FooterCenter>{children}</FooterCenter>
<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,
@@ -99,4 +107,3 @@ const Footer = ({
};
export default Footer;
Footer.displayName = "Footer";
+15 -50
View File
@@ -1,6 +1,6 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin, isWindows, KEYS } from "../keys";
import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
@@ -118,49 +118,26 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--tools"
caption={t("helpDialog.tools")}
>
<Shortcut
label={t("toolBar.selection")}
shortcuts={[KEYS.V, KEYS["1"]]}
/>
<Shortcut
label={t("toolBar.rectangle")}
shortcuts={[KEYS.R, KEYS["2"]]}
/>
<Shortcut
label={t("toolBar.diamond")}
shortcuts={[KEYS.D, KEYS["3"]]}
/>
<Shortcut
label={t("toolBar.ellipse")}
shortcuts={[KEYS.O, KEYS["4"]]}
/>
<Shortcut
label={t("toolBar.arrow")}
shortcuts={[KEYS.A, KEYS["5"]]}
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.P, KEYS["6"]]}
/>
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.freedraw")}
shortcuts={["Shift + P", KEYS["7"]]}
shortcuts={["Shift + P", "X", "7"]}
/>
<Shortcut
label={t("toolBar.text")}
shortcuts={[KEYS.T, KEYS["8"]]}
/>
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
shortcuts={[getShortcutKey("E")]}
/>
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
getShortcutKey("CtrlOrCmd+Enter"),
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
]}
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
/>
<Shortcut
label={t("helpDialog.textNewLine")}
@@ -196,7 +173,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]}
isOr={false}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
@@ -230,14 +207,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
@@ -300,10 +269,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
@@ -318,7 +283,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Delete")]}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}
+21 -14
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
@@ -31,6 +33,19 @@ export const ErrorCanvasPreview = () => {
);
};
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
@@ -64,7 +79,6 @@ const ImageExportModal = ({
elements,
appState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
onExportToSvg,
@@ -73,7 +87,6 @@ const ImageExportModal = ({
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
@@ -84,7 +97,6 @@ const ImageExportModal = ({
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
@@ -102,34 +114,30 @@ const ImageExportModal = ({
exportToCanvas(exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
exportPadding,
exportPadding: appState.exportPadding,
})
.then((canvas) => {
setRenderError(null);
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
previewNode.replaceChildren(canvas);
renderPreview(canvas, previewNode);
});
})
.catch((error) => {
console.error(error);
setRenderError(error);
renderPreview(new CanvasError(), previewNode);
});
}, [
appState,
files,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
]);
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
<div className="ExportDialog__preview" ref={previewRef} />
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
@@ -140,8 +148,10 @@ const ImageExportModal = ({
// dunno why this is needed, but when the items wrap it creates
// an overflow
overflow: "hidden",
gap: ".6rem",
}}
>
{actionManager.renderAction("changeExportPadding")}
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<CheckboxItem
@@ -210,7 +220,6 @@ export const ImageExportDialog = ({
appState,
setAppState,
files,
exportPadding = DEFAULT_EXPORT_PADDING,
actionManager,
onExportToPng,
onExportToSvg,
@@ -220,7 +229,6 @@ export const ImageExportDialog = ({
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
@@ -238,7 +246,6 @@ export const ImageExportDialog = ({
elements={elements}
appState={appState}
files={files}
exportPadding={exportPadding}
actionManager={actionManager}
onExportToPng={onExportToPng}
onExportToSvg={onExportToSvg}
-4
View File
@@ -85,10 +85,6 @@
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}
.layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right,
+17 -30
View File
@@ -8,14 +8,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types";
import {
AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIChildrenComponents,
} from "../types";
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
@@ -44,7 +38,7 @@ import { trackEvent } from "../analytics";
import { isMenuOpenAtom, useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import Footer from "./Footer";
import {
ExportImageIcon,
HamburgerMenuIcon,
@@ -77,6 +71,7 @@ interface LayerUIProps {
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -86,9 +81,7 @@ interface LayerUIProps {
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean;
children?: React.ReactNode;
}
const LayerUI = ({
actionManager,
appState,
@@ -103,7 +96,7 @@ const LayerUI = ({
showExitZenModeBtn,
isCollaborating,
renderTopRightUI,
renderCustomFooter,
renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
@@ -113,13 +106,9 @@ const LayerUI = ({
id,
onImageAction,
renderWelcomeScreen,
children,
}: LayerUIProps) => {
const device = useDevice();
const childrenComponents =
ReactChildrenToObject<UIChildrenComponents>(children);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
@@ -155,6 +144,7 @@ const LayerUI = ({
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
exportPadding: appState.exportPadding,
},
)
.catch(muteFSAbortError)
@@ -207,7 +197,6 @@ const LayerUI = ({
})}
onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button"
data-testid="menu-button"
>
{HamburgerMenuIcon}
</button>
@@ -232,15 +221,13 @@ const LayerUI = ({
{appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()}
{UIOptions.canvasActions.saveAsImage && (
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
)}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
@@ -492,6 +479,7 @@ const LayerUI = ({
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
@@ -524,11 +512,9 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen}
appState={appState}
actionManager={actionManager}
renderCustomFooter={renderCustomFooter}
showExitZenModeBtn={showExitZenModeBtn}
>
{childrenComponents.FooterCenter}
</Footer>
/>
{appState.showStats && (
<Stats
appState={appState}
@@ -575,6 +561,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
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 &&
+1 -1
View File
@@ -22,7 +22,7 @@ export const LibraryButton: React.FC<{
}
return (
<label title={`${capitalizeString(t("toolBar.library"))}`}>
<label title={`${capitalizeString(t("toolBar.library"))} — 0`}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
-1
View File
@@ -44,7 +44,6 @@ export const LibraryUnit = ({
},
null,
);
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML;
})();
+6 -1
View File
@@ -36,7 +36,10 @@ type MobileMenuProps = {
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: (
isMobile: boolean,
@@ -60,6 +63,7 @@ export const MobileMenu = ({
onPenModeToggle,
canvas,
isCollaborating,
renderCustomFooter,
onImageAction,
renderTopRightUI,
renderCustomStats,
@@ -249,6 +253,7 @@ export const MobileMenu = ({
<div className="panelColumn">
<Stack.Col gap={2}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
-1
View File
@@ -46,7 +46,6 @@ const ChartPreviewBtn = (props: {
},
null, // files
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
+2 -2
View File
@@ -90,10 +90,10 @@ describe("Sidebar", () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
const closeButton = queryByTestId(sidebar!, "sidebar-close");
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
fireEvent.click(closeButton!.querySelector("button")!);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled();
-1
View File
@@ -7,7 +7,6 @@
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.625rem;
&:empty {
+5 -1
View File
@@ -2,7 +2,7 @@ import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { isExcalidrawPlusSignedUser } from "../constants";
import { COOKIES } from "../constants";
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
import { t } from "../i18n";
import { AppState } from "../types";
@@ -15,6 +15,10 @@ import {
} from "./icons";
import "./WelcomeScreen.scss";
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const WelcomeScreenItem = ({
label,
shortcut,
-19
View File
@@ -1,19 +0,0 @@
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const appState = useExcalidrawAppState();
return (
<div
className={clsx("layer-ui__wrapper__footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
);
};
export default FooterCenter;
FooterCenter.displayName = "FooterCenter";
+9 -9
View File
@@ -1470,11 +1470,11 @@ export const TextAlignRightIcon = createIcon(
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="4" x2="20" y2="4" />
@@ -1488,11 +1488,11 @@ export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="2"
stroke-width="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="20" x2="20" y2="20" />
@@ -1506,11 +1506,11 @@ export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<g
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="12" x2="9" y2="12" />
+6 -30
View File
@@ -130,6 +130,12 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
export const MODES = {
VIEW: "viewMode",
ZEN: "zenMode",
GRID: "gridMode",
};
export const THEME_FILTER = cssVariables.themeFilter;
export const URL_QUERY_KEYS = {
@@ -210,32 +216,6 @@ export const TEXT_ALIGN = {
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
// below the cutoff size.
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
// Fixed radius for the ADAPTIVE_RADIUS algorithm. In pixels.
export const DEFAULT_ADAPTIVE_RADIUS = 32;
// roundness type (algorithm)
export const ROUNDNESS = {
// Used for legacy rounding (rectangles), which currently works the same
// as PROPORTIONAL_RADIUS, but we need to differentiate for UI purposes and
// forwards-compat.
LEGACY: 1,
// Used for linear elements & diamonds
PROPORTIONAL_RADIUS: 2,
// Current default algorithm for rectangles, using fixed pixel radius.
// It's working similarly to a regular border-radius, but attemps to make
// radius visually similar across differnt element sizes, especially
// very large and very small elements.
//
// NOTE right now we don't allow configuration and use a constant radius
// (see DEFAULT_ADAPTIVE_RADIUS constant)
ADAPTIVE_RADIUS: 3,
} as const;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
@@ -243,7 +223,3 @@ export const COOKIES = {
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
+1 -2
View File
@@ -154,8 +154,7 @@ class Library {
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
const source = await (typeof libraryItems === "function"
? libraryItems(this.lastLibraryItems)
: libraryItems);
+10 -121
View File
@@ -1,9 +1,7 @@
import {
ExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
StrokeRoundness,
} from "../element/types";
import {
AppState,
@@ -18,7 +16,7 @@ import {
isInvisiblySmallElement,
refreshTextDimensions,
} from "../element";
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
DEFAULT_FONT_FAMILY,
@@ -26,14 +24,12 @@ import {
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
} from "../constants";
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import oc from "open-color";
type RestoredAppState = Omit<
AppState,
@@ -77,8 +73,6 @@ const restoreElementWithProperties = <
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string;
},
@@ -111,23 +105,15 @@ const restoreElementWithProperties = <
angle: element.angle || 0,
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || oc.black,
backgroundColor: element.backgroundColor || "transparent",
strokeColor: element.strokeColor,
backgroundColor: element.backgroundColor,
width: element.width || 0,
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
roundness: element.roundness
? element.roundness
: element.strokeSharpness === "round"
? {
// for old elements that would now use adaptive radius algo,
// use legacy algo instead
type: isUsingAdaptiveRadius(element.type)
? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
strokeSharpness:
element.strokeSharpness ??
(isLinearElementType(element.type) ? "round" : "sharp"),
boundElements: element.boundElementIds
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
@@ -153,7 +139,7 @@ const restoreElementWithProperties = <
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
refreshDimensions = false,
refreshDimensions = true,
): typeof element | null => {
switch (element.type) {
case "text":
@@ -249,99 +235,14 @@ const restoreElement = (
}
};
/**
* Repairs contaienr element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*
* NOTE mutates elements.
*/
const repairContainerElement = (
container: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (container.boundElements) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = container.boundElements.slice();
// dedupe bindings & fix boundElement.containerId if not set already
const boundIds = new Set<ExcalidrawElement["id"]>();
container.boundElements = boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const boundElement = elementsMap.get(binding.id);
if (boundElement && !boundIds.has(binding.id)) {
boundIds.add(binding.id);
if (boundElement.isDeleted) {
return acc;
}
acc.push(binding);
if (
isTextElement(boundElement) &&
// being slightly conservative here, preserving existing containerId
// if defined, lest boundElements is stale
!boundElement.containerId
) {
(boundElement as Mutable<ExcalidrawTextElement>).containerId =
container.id;
}
}
return acc;
},
[],
);
}
};
/**
* Repairs target bound element's container's boundElements array,
* or removes contaienrId if container does not exist.
*
* NOTE mutates elements.
*/
const repairBoundElement = (
boundElement: Mutable<ExcalidrawTextElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
const container = boundElement.containerId
? elementsMap.get(boundElement.containerId)
: null;
if (!container) {
boundElement.containerId = null;
return;
}
if (boundElement.isDeleted) {
return;
}
if (
container.boundElements &&
!container.boundElements.find((binding) => binding.id === boundElement.id)
) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = (
container.boundElements || (container.boundElements = [])
).slice();
boundElements.push({ type: "text", id: boundElement.id });
container.boundElements = boundElements;
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
refreshDimensions = false,
refreshDimensions = true,
): ExcalidrawElement[] => {
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (elements || []).reduce((elements, element) => {
return (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
@@ -359,18 +260,6 @@ export const restoreElements = (
}
return elements;
}, [] as ExcalidrawElement[]);
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
repairContainerElement(element, restoredElementsMap);
}
}
return restoredElements;
};
const coalesceAppStateValue = <
@@ -498,7 +387,7 @@ export const restore = (
localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements),
elements: restoreElements(data?.elements, localElements, true),
appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {},
};
-5
View File
@@ -26,7 +26,6 @@ import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -362,10 +361,6 @@ export const updateBoundElements = (
endBinding,
changedElement as ExcalidrawBindableElement,
);
const boundText = getBoundTextElement(element);
if (boundText) {
handleBindTextResize(element, false);
}
});
};
-2
View File
@@ -1,4 +1,3 @@
import { ROUNDNESS } from "../constants";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
@@ -23,7 +22,6 @@ const _ce = ({
backgroundColor: "#000",
fillStyle: "solid",
strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 0,
opacity: 1,
x,
+76 -89
View File
@@ -4,7 +4,6 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
} from "./types";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
@@ -14,15 +13,8 @@ import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
@@ -32,39 +24,17 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
): Bounds => {
if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
return LinearElementEditor.getElementAbsoluteCoords(
element,
includeBoundText,
);
} else if (isTextElement(element)) {
const container = getContainerElement(element);
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
];
}
return getLinearElementAbsoluteCoords(element);
}
return [
element.x,
element.y,
element.x + element.width,
element.y + element.height,
element.x + element.width / 2,
element.y + element.height / 2,
];
};
@@ -189,7 +159,7 @@ const getCubicBezierCurveBound = (
return [minX, minY, maxX, maxY];
};
export const getMinMaxXYFromCurvePathOps = (
const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => {
@@ -260,13 +230,59 @@ const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number, number, number] => {
): [number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const x1 = minX + element.x;
const y1 = minY + element.y;
const x2 = maxX + element.x;
const y2 = maxY + element.y;
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
const getLinearElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
): [number, number, number, number] => {
let coords: [number, number, number, number];
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
}
return coords;
};
export const getArrowheadPoints = (
@@ -378,7 +394,7 @@ const generateLinearElementShape = (
const options = generateRoughOptions(element);
const method = (() => {
if (element.roundness) {
if (element.strokeSharpness !== "sharp") {
return "curve";
}
if (options.fill) {
@@ -404,23 +420,7 @@ const getLinearElementRotatedBounds = (
cy,
element.angle,
);
let coords: [number, number, number, number] = [x, y, x, y];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x, y, x, y],
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
return [x, y, x, y];
}
// first element is always the curve
@@ -429,28 +429,8 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: [number, number, number, number] = [
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
coords,
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
return getMinMaxXYFromCurvePathOps(ops, transformXY);
};
// We could cache this stuff
@@ -459,7 +439,9 @@ export const getElementBounds = (
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
@@ -510,7 +492,7 @@ export const getElementBounds = (
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
): [number, number, number, number] => {
): [minX: number, minY: number, maxX: number, maxY: number] => {
if (!elements.length) {
return [0, 0, 0, 0];
}
@@ -561,12 +543,16 @@ export const getResizedElementAbsoluteCoords = (
} else {
// Line
const gen = rough.generator();
const curve = !element.roundness
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(points as [number, number][], generateRoughOptions(element));
const curve =
element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
@@ -584,11 +570,12 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
sharpness: ExcalidrawElement["strokeSharpness"],
): [number, number, number, number] => {
// This might be computationally heavey
const gen = rough.generator();
const curve =
element.roundness == null
sharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
+9 -31
View File
@@ -25,7 +25,6 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
StrokeRoundness,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -37,7 +36,6 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -74,13 +72,6 @@ export const hitTest = (
return isPointHittingElementBoundingBox(element, point, threshold);
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
if (isHittingBoundTextElement) {
return true;
}
}
return isHittingElementNotConsideringBoundingBox(element, appState, point);
};
@@ -92,13 +83,6 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
): boolean => {
const threshold = 10 / appState.zoom.value;
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
return false;
}
return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
@@ -111,6 +95,7 @@ export const isHittingElementNotConsideringBoundingBox = (
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
const check = isTextElement(element)
? isStrictlyInside
: isElementDraggableFromInside(element)
@@ -397,7 +382,6 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (!getShapeForElement(element)) {
return false;
}
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
args.element,
args.point,
@@ -420,12 +404,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (args.check === isInsideCheck) {
const hit = shape.some((subshape) =>
hitTestCurveInside(
subshape,
relX,
relY,
element.roundness ? "round" : "sharp",
),
hitTestCurveInside(subshape, relX, relY, element.strokeSharpness),
);
if (hit) {
return true;
@@ -455,9 +434,8 @@ const pointRelativeToElement = (
pointTuple: Point,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const center = coordsCenter(elementCoords);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
@@ -488,8 +466,8 @@ export const pointInAbsoluteCoords = (
const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse(
@@ -546,8 +524,8 @@ export const determineFocusPoint = (
adjecentPoint: Point,
): Point => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
@@ -857,7 +835,7 @@ const hitTestCurveInside = (
drawable: Drawable,
x: number,
y: number,
roundness: StrokeRoundness,
sharpness: ExcalidrawElement["strokeSharpness"],
) => {
const ops = getCurvePathOps(drawable);
const points: Mutable<Point>[] = [];
@@ -881,7 +859,7 @@ const hitTestCurveInside = (
}
}
if (points.length >= 4) {
if (roundness === "sharp") {
if (sharpness === "sharp") {
return isPointInPolygon(points, x, y);
}
const polygonPoints = pointsOnBezierCurves(points, 10, 5);
+54 -349
View File
@@ -4,7 +4,6 @@ import {
ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
} from "./types";
import {
distance2d,
@@ -20,12 +19,8 @@ import {
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import {
getCurvePathOps,
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { Point, AppState, PointerCoords } from "../types";
import { getElementPointsCoords } from "./bounds";
import { Point, AppState } from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
@@ -38,15 +33,13 @@ import {
import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
@@ -58,12 +51,6 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null;
/** index */
lastClickedPoint: number;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
index: number | null;
added: boolean;
};
}>;
/** whether you're dragging a point */
@@ -94,13 +81,6 @@ export class LinearElementEditor {
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
origin: null,
segmentMidpoint: {
value: null,
index: null,
added: false,
},
};
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
@@ -200,7 +180,6 @@ export class LinearElementEditor {
const draggingPoint = element.points[
linearElementEditor.pointerDownState.lastClickedPoint
] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) {
if (
shouldRotateWithDiscreteAngle(event) &&
@@ -263,11 +242,6 @@ export class LinearElementEditor {
};
}),
);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
handleBindTextResize(element, false);
}
}
// suggest bindings for first and last point if selected
@@ -399,14 +373,8 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
): typeof editorMidPointsCache["points"] => {
const boundText = getBoundTextElement(element);
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
) {
// Since its not needed outside editor unless 2 pointer lines
if (!appState.editingLinearElement && element.points.length > 2) {
return [];
}
if (
@@ -527,7 +495,7 @@ export class LinearElementEditor {
endPoint[0],
endPoint[1],
);
if (element.points.length > 2 && element.roundness) {
if (element.points.length > 2 && element.strokeSharpness === "round") {
distance = getBezierCurveLength(element, endPoint);
}
@@ -541,7 +509,7 @@ export class LinearElementEditor {
endPointIndex: number,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
if (element.points.length > 2 && element.strokeSharpness === "round") {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
@@ -583,7 +551,7 @@ export class LinearElementEditor {
}
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
let index = 0;
while (index < midPoints.length) {
while (index < midPoints.length - 1) {
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
return index + 1;
}
@@ -602,11 +570,13 @@ export class 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) {
@@ -619,18 +589,43 @@ export class LinearElementEditor {
if (!element) {
return ret;
}
const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords(
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor,
scenePointer,
appState,
);
let segmentMidpointIndex = null;
if (segmentMidpoint) {
segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex(
if (segmentMidPoint) {
const index = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor,
appState,
segmentMidpoint,
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) {
@@ -653,12 +648,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
@@ -678,9 +667,10 @@ export class LinearElementEditor {
scenePointer.x,
scenePointer.y,
);
// 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 >= 0 || segmentMidPoint) {
ret.hitElement = element;
} else {
// You might be wandering why we are storing the binding elements on
@@ -726,12 +716,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
},
selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint
@@ -1071,6 +1055,7 @@ export class LinearElementEditor {
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
@@ -1126,94 +1111,6 @@ export class LinearElementEditor {
);
}
static shouldAddMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return false;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
if (
segmentMidpoint.added ||
segmentMidpoint.value === null ||
segmentMidpoint.index === null ||
linearElementEditor.pointerDownState.origin === null
) {
return false;
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
);
if (
!appState.editingLinearElement &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;
}
return true;
}
static addMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
const ret: {
pointerDownState: LinearElementEditor["pointerDownState"];
selectedPointsIndices: LinearElementEditor["selectedPointsIndices"];
} = {
pointerDownState: linearElementEditor.pointerDownState,
selectedPointsIndices: linearElementEditor.selectedPointsIndices,
};
const midpoint = LinearElementEditor.createPointAt(
element,
pointerCoords.x,
pointerCoords.y,
appState.gridSize,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
midpoint,
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
segmentMidpoint: {
...linearElementEditor.pointerDownState.segmentMidpoint,
added: true,
},
lastClickedPoint: segmentMidpoint.index!,
};
ret.selectedPointsIndices = [segmentMidpoint.index!];
return ret;
}
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
@@ -1221,8 +1118,16 @@ export class LinearElementEditor {
offsetY: number,
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
const nextCoords = getElementPointsCoords(
element,
nextPoints,
element.strokeSharpness || "round",
);
const prevCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness || "round",
);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
@@ -1230,6 +1135,7 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@@ -1264,207 +1170,6 @@ export class LinearElementEditor {
return rotatePoint([width, height], [0, 0], -element.angle);
}
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
): { x: number; y: number } => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
}
let x = 0;
let y = 0;
if (element.points.length % 2 === 1) {
const index = Math.floor(element.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[index],
);
x = midPoint[0] - boundTextElement.width / 2;
y = midPoint[1] - boundTextElement.height / 2;
} else {
const index = element.points.length / 2 - 1;
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
editorMidPointsCache.version !== element.version
) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
}
return { x, y };
};
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number],
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const { x: boundTextX1, y: boundTextY1 } =
LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
);
if (
topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextBottomRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
} else if (
topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] > topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopLeft[0],
counterRotateBoundTextTopRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
} else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
} else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
x1 = Math.min(
x1,
Math.min(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextTopLeft[0],
),
);
x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
}
return [x1, y1, x2, y2, cx, cy];
};
static getElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
let coords: [number, number, number, number, number, number];
let x1;
let y1;
let x2;
let y2;
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
coords = [x1, y1, x2, y2, cx, cy];
if (!includeBoundText) {
return coords;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
coords = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x1, y1, x2, y2],
boundTextElement,
);
}
return coords;
};
}
const normalizeSelectedPoints = (
+3 -3
View File
@@ -1,7 +1,7 @@
import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { FONT_FAMILY } from "../constants";
import { isPrimitive } from "../utils";
const assertCloneObjects = (source: any, clone: any) => {
@@ -25,7 +25,7 @@ it("clones arrow element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
strokeSharpness: "round",
roughness: 1,
opacity: 100,
});
@@ -71,7 +71,7 @@ it("clones text element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
strokeSharpness: "round",
roughness: 1,
opacity: 100,
text: "hello",
+19 -55
View File
@@ -11,7 +11,7 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawTextContainer,
ExcalidrawRectangleElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -22,16 +22,12 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
getBoundTextElement,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
normalizeText,
wrapText,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -62,15 +58,14 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0,
angle = 0,
groupIds = [],
roundness = null,
strokeSharpness,
boundElements = null,
link = null,
locked,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
const element = {
id: rest.id || randomId(),
type,
x,
@@ -86,7 +81,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness,
opacity,
groupIds,
roundness,
strokeSharpness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
@@ -135,16 +130,15 @@ export const newTextElement = (
fontFamily: FontFamilyValues;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"];
containerId?: ExcalidrawRectangleElement["id"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const metrics = measureText(opts.text, getFontString(opts));
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith(
{
..._newElementBase<ExcalidrawTextElement>("text", opts),
text,
text: opts.text,
fontSize: opts.fontSize,
fontFamily: opts.fontFamily,
textAlign: opts.textAlign,
@@ -155,7 +149,7 @@ export const newTextElement = (
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
originalText: opts.text,
},
{},
);
@@ -175,7 +169,8 @@ const getAdjustedDimensions = (
let maxWidth = null;
const container = getContainerElement(element);
if (container) {
maxWidth = getMaxContainerWidth(container);
const containerDims = getContainerDims(container);
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
}
const {
width: nextWidth,
@@ -235,21 +230,16 @@ const getAdjustedDimensions = (
// make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions
if (container) {
const boundTextElementPadding = getBoundTextElementOffset(element);
const containerDims = getContainerDims(container);
let height = containerDims.height;
let width = containerDims.width;
if (nextHeight > height - boundTextElementPadding * 2) {
height = nextHeight + boundTextElementPadding * 2;
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
height = nextHeight + BOUND_TEXT_PADDING * 2;
}
if (nextWidth > width - boundTextElementPadding * 2) {
width = nextWidth + boundTextElementPadding * 2;
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
width = nextWidth + BOUND_TEXT_PADDING * 2;
}
if (
!isArrowElement(container) &&
(height !== containerDims.height || width !== containerDims.width)
) {
if (height !== containerDims.height || width !== containerDims.width) {
mutateElement(container, { height, width });
}
}
@@ -268,6 +258,7 @@ export const refreshTextDimensions = (
) => {
const container = getContainerElement(textElement);
if (container) {
// text = wrapText(text, getFontString(textElement), container.width);
text = wrapText(
text,
getFontString(textElement),
@@ -279,35 +270,11 @@ export const refreshTextDimensions = (
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = (
@@ -399,8 +366,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
// don't copy top-level shape property, which we want to regenerate
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
@@ -443,7 +409,6 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>,
): TElement => {
let copy: TElement = deepCopyElement(element);
if (isTestEnv()) {
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
@@ -457,7 +422,6 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} else {
copy.id = randomId();
}
copy.boundElements = null;
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(
+126 -68
View File
@@ -1,4 +1,4 @@
import { SHIFT_LOCKING_ANGLE } from "../constants";
import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import {
@@ -13,7 +13,6 @@ import {
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
} from "./types";
import {
getElementAbsoluteCoords,
@@ -22,13 +21,12 @@ import {
getCommonBoundingBox,
} from "./bounds";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
@@ -43,12 +41,9 @@ import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureText,
} from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@@ -79,9 +74,23 @@ export const transformElements = (
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
pointerDownState.originalElements,
);
updateBoundElements(element);
} else if (
isLinearElement(element) &&
element.points.length === 2 &&
(transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se")
) {
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
shouldRotateWithDiscreteAngle,
pointerX,
pointerY,
);
} else if (
isTextElement(element) &&
(transformHandleType === "nw" ||
@@ -147,7 +156,6 @@ const rotateSingleElement = (
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
@@ -158,21 +166,100 @@ const rotateSingleElement = (
angle -= angle % SHIFT_LOCKING_ANGLE;
}
angle = normalizeAngle(angle);
const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle });
}
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
mutateElement(textElement!, { angle });
}
};
// used in DEV only
const validateTwoPointElementNormalized = (
element: NonDeleted<ExcalidrawLinearElement>,
) => {
if (
element.points.length !== 2 ||
element.points[0][0] !== 0 ||
element.points[0][1] !== 0 ||
Math.abs(element.points[1][0]) !== element.width ||
Math.abs(element.points[1][1]) !== element.height
) {
throw new Error("Two-point element is not normalized");
}
};
const getPerfectElementSizeWithRotation = (
elementType: ExcalidrawElement["type"],
width: number,
height: number,
angle: number,
): [number, number] => {
const size = getPerfectElementSize(
elementType,
...rotate(width, height, 0, 0, angle),
);
return rotate(size.width, size.height, 0, 0, -angle);
};
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
shouldRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
if (process.env.NODE_ENV !== "production") {
validateTwoPointElementNormalized(element);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
);
let [width, height] =
resizeArrowDirection === "end"
? [rotatedX - element.x, rotatedY - element.y]
: [
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (shouldRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
height,
element.angle,
);
}
const [nextElementX, nextElementY] = adjustXYWithRotation(
resizeArrowDirection === "end"
? { s: true, e: true }
: { n: true, w: true },
element.x,
element.y,
element.angle,
0,
0,
(element.points[1][0] - width) / 2,
(element.points[1][1] - height) / 2,
);
mutateElement(element, {
x: nextElementX,
y: nextElementY,
points: [
[0, 0],
[width, height],
],
});
};
const rescalePointsInElement = (
element: NonDeletedExcalidrawElement,
width: number,
@@ -198,23 +285,14 @@ const measureFontSizeFromWH = (
nextHeight: number,
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize
let width = element.width;
const hasContainer = isBoundToContainer(element);
if (hasContainer) {
const container = getContainerElement(element);
if (container) {
width = getMaxContainerWidth(container);
}
}
const nextFontSize = element.fontSize * (nextWidth / width);
const nextFontSize = element.fontSize * (nextWidth / element.width);
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? width : null,
element.containerId ? element.width : null,
);
return {
size: nextFontSize,
@@ -427,12 +505,10 @@ export const resizeSingleElement = (
};
}
if (shouldMaintainAspectRatio) {
const boundTextElementPadding =
getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH(
boundTextElement,
eleNewWidth - boundTextElementPadding * 2,
eleNewHeight - boundTextElementPadding * 2,
eleNewWidth - BOUND_TEXT_PADDING * 2,
eleNewHeight - BOUND_TEXT_PADDING * 2,
);
if (nextFont === null) {
return;
@@ -521,36 +597,24 @@ export const resizeSingleElement = (
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// Readjust points for linear elements
let rescaledElementPointsY;
let rescaledPoints;
if (isLinearElement(element) || isFreeDrawElement(element)) {
rescaledElementPointsY = rescalePoints(
1,
eleNewHeight,
(stateAtResizeStart as ExcalidrawLinearElement).points,
true,
);
rescaledPoints = rescalePoints(
0,
eleNewWidth,
rescaledElementPointsY,
true,
);
}
const rescaledPoints = rescalePointsInElement(
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
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
const resizedElement = {
width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight),
x: newOrigin[0],
y: newOrigin[1],
points: rescaledPoints,
...rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
@@ -574,7 +638,6 @@ export const resizeSingleElement = (
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
@@ -697,7 +760,7 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
@@ -727,7 +790,6 @@ const resizeMultipleElements = (
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType);
}
});
@@ -748,7 +810,7 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
elements.forEach((element) => {
elements.forEach((element, index) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
@@ -769,16 +831,12 @@ const rotateMultipleElements = (
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
Scene.getScene(element)!.getElement(boundTextElementId)!;
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
});
};
+1 -1
View File
@@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
pointerType: PointerType,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
[x1, y1, x2, y2],
0,
zoom,
pointerType,
+1 -42
View File
@@ -1,17 +1,10 @@
import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement";
import { wrapText } from "./textElement";
import { FontString } from "./types";
describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello whats up ");
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
@@ -146,37 +139,3 @@ break it now`,
});
});
});
describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const text = "Hello World";
it("should add correct attributes when maxWidth is passed", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = measureText(text, font, maxWidth);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; width: 111px; overflow: hidden; word-break: break-word; line-height: 0px;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
it("should add correct attributes when maxWidth is not passed", () => {
const res = measureText(text, font);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
});
+121 -350
View File
@@ -1,7 +1,6 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
@@ -13,31 +12,6 @@ import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import {
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from "../element";
import { getSelectedElements } from "../scene";
import { isHittingElementNotConsideringBoundingBox } from "./collision";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./textWysiwyg";
export const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
@@ -45,60 +19,54 @@ export const redrawTextBoundingBox = (
) => {
let maxWidth = undefined;
let text = textElement.text;
if (container) {
maxWidth = getMaxContainerWidth(container);
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
getMaxContainerWidth(container),
);
}
const metrics = measureText(text, getFontString(textElement), maxWidth);
const metrics = measureText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
let coordY = textElement.y;
let coordX = textElement.x;
// Resize container and vertically center align the text
if (container) {
if (!isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
const boundTextElementPadding = getBoundTextElementOffset(textElement);
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + boundTextElementPadding;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
boundTextElementPadding;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + boundTextElementPadding * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + boundTextElementPadding;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
boundTextElementPadding;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + BOUND_TEXT_PADDING;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2;
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
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, {
width: metrics.width,
height: metrics.height,
@@ -128,7 +96,7 @@ export const bindTextToShapeAfterDuplication = (
const newContainer = sceneElementMap.get(newElementId);
if (newContainer) {
mutateElement(newContainer, {
boundElements: (newContainer.boundElements || []).concat({
boundElements: element.boundElements?.concat({
type: "text",
id: newTextElementId,
}),
@@ -146,114 +114,84 @@ export const bindTextToShapeAfterDuplication = (
};
export const handleBindTextResize = (
container: NonDeletedExcalidrawElement,
element: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType,
) => {
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
return;
}
resetOriginalContainerCache(container.id);
let textElement = Scene.getScene(container)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
if (textElement && textElement.text) {
if (!container) {
return;
}
textElement = Scene.getScene(container)!.getElement(
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
let text = textElement.text;
let nextHeight = textElement.height;
let nextWidth = textElement.width;
const containerDims = getContainerDims(container);
const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
if (textElement && textElement.text) {
if (!element) {
return;
}
const dimensions = measureText(
text,
getFontString(textElement),
maxWidth,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
!isArrowElement(container) &&
(transformHandleType === "ne" ||
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") {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
getMaxContainerWidth(element),
);
}
const dimensions = measureText(
text,
getFontString(textElement),
element.width,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
const diff = containerHeight - element.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
transformHandleType === "ne" ||
transformHandleType === "nw" ||
transformHandleType === "n")
? container.y - diff
: container.y;
mutateElement(container, {
height: containerHeight,
transformHandleType === "n"
? element.y - diff
: element.y;
mutateElement(element, {
height: containerHeight,
y: updatedY,
});
}
let updatedY;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
updatedY = element.y + BOUND_TEXT_PADDING;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
} 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,
height: nextHeight,
x: updatedX,
y: updatedY,
baseline: nextBaseLine,
});
}
mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
updateBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
);
}
}
};
const updateBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const containerDims = getContainerDims(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y =
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
}
const x =
boundTextElement.textAlign === TEXT_ALIGN.LEFT
? container.x + boundTextElementPadding
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
? container.x +
containerDims.width -
boundTextElement.width -
boundTextElementPadding
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
mutateElement(boundTextElement, { x, y });
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
@@ -271,12 +209,9 @@ export const measureText = (
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
const textWidth = getTextWidth(text, font);
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
container.style.width = `${String(Math.min(textWidth, maxWidth) + 1)}px`;
container.style.maxWidth = `${String(maxWidth)}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
@@ -294,15 +229,11 @@ export const measureText = (
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
// Since span adds 1px extra width to the container
let width = container.offsetWidth;
if (maxWidth && textWidth > maxWidth) {
width = width - 1;
}
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline };
};
@@ -318,7 +249,7 @@ export const getApproxLineHeight = (font: FontString) => {
};
let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => {
const getTextWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
@@ -336,24 +267,10 @@ const getLineWidth = (text: string, font: FontString) => {
return metrics.width;
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n");
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font);
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const spaceWidth = getTextWidth(" ", font);
originalLines.forEach((originalLine) => {
const words = originalLine.split(" ");
// This means its newline so push it
@@ -365,13 +282,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
const currentWordWidth = getTextWidth(words[index], font);
// Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
if (currentLine) {
lines.push(currentLine);
}
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
@@ -385,7 +304,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
lines.push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
if (currentLineWidthTillNow === maxWidth) {
@@ -398,7 +317,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
lines.push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
} else {
@@ -414,10 +333,10 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
currentLineWidthTillNow = getTextWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
lines.push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
@@ -428,8 +347,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
const word = currentLine.slice(0, -1);
push(word);
lines.push(currentLine.slice(0, -1));
currentLine = "";
currentLineWidthTillNow = 0;
break;
@@ -446,7 +364,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
push(currentLine);
lines.push(currentLine);
}
}
});
@@ -462,7 +380,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
const width = getTextWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
@@ -479,7 +397,6 @@ export const charWidth = (() => {
})();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@@ -522,7 +439,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getLineWidth(str, font);
widthTillNow += getTextWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
@@ -531,7 +448,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getLineWidth(str, font);
widthTillNow = getTextWidth(str, font);
}
return str.length;
};
@@ -560,9 +477,7 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
export const getContainerElement = (
element:
| (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
| null,
) => {
if (!element) {
@@ -575,149 +490,5 @@ export const getContainerElement = (
};
export const getContainerDims = (element: ExcalidrawElement) => {
const MIN_WIDTH = 300;
if (isArrowElement(element)) {
const width = Math.max(element.width, MIN_WIDTH);
const height = element.height;
return { width, height };
}
return { width: element.width, height: element.height };
};
export const getContainerCenter = (
container: ExcalidrawElement,
appState: AppState,
) => {
if (!isArrowElement(container)) {
return {
x: container.x + container.width / 2,
y: container.y + container.height / 2,
};
}
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
if (points.length % 2 === 1) {
const index = Math.floor(container.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
container,
container.points[index],
);
return { x: midPoint[0], y: midPoint[1] };
}
const index = container.points.length / 2 - 1;
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
container,
appState,
)[index];
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
points[index],
points[index + 1],
index + 1,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) {
return textElement.angle;
}
return container.angle;
};
export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const container = getContainerElement(boundTextElement);
if (!container) {
return 0;
}
if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8;
}
return BOUND_TEXT_PADDING;
};
export const getBoundTextElementPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
};
export const shouldAllowVerticalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
) => {
return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element);
if (hasBoundContainer) {
const container = getContainerElement(element);
if (isTextElement(element) && isArrowElement(container)) {
return false;
}
return true;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
if (isArrowElement(element)) {
return false;
}
return true;
}
return false;
});
};
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
return isTextBindableContainer(selectedElements[0], false)
? selectedElements[0]
: null;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if (
isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
x,
y,
])
) {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
export const isValidTextContainer = (element: ExcalidrawElement) => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
isImageElement(element) ||
isArrowElement(element)
);
};
+30 -319
View File
@@ -15,7 +15,6 @@ import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -463,7 +462,7 @@ describe("textWysiwyg", () => {
});
});
it("should bind text to container when double clicked on center of filled container", async () => {
it("should bind text to container when double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -473,43 +472,6 @@ describe("textWysiwyg", () => {
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should bind text to container when double clicked on center of transparent container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "transparent",
});
h.elements = [rectangle];
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
@@ -552,19 +514,20 @@ describe("textWysiwyg", () => {
});
it("shouldn't bind to non-text-bindable containers", async () => {
const freedraw = API.createElement({
type: "freedraw",
const line = API.createElement({
type: "line",
width: 100,
height: 0,
points: [
[0, 0],
[100, 0],
],
});
h.elements = [freedraw];
h.elements = [line];
UI.clickTool("text");
mouse.clickAt(
freedraw.x + freedraw.width / 2,
freedraw.y + freedraw.height / 2,
);
mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
@@ -578,24 +541,11 @@ describe("textWysiwyg", () => {
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input"));
expect(freedraw.boundElements).toBe(null);
expect(line.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text");
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
});
["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
h.elements = [];
const elemnet = UI.createElement(type, {
width: 100,
height: 50,
});
API.setSelectedElements([elemnet]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(1);
});
});
it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -826,9 +776,9 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(2);
// Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = document.querySelector(
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
@@ -838,14 +788,25 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" },
]);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
// Attempt to bind another text
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
expect(text.containerId).toBe(null);
});
it("should respect text alignment when resizing", async () => {
@@ -862,7 +823,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
110,
109.5,
17,
]
`);
@@ -910,260 +871,10 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [
425,
424,
-539,
]
`);
});
it("should always bind to selected container and insert it in correct position", async () => {
const rectangle2 = UI.createElement("rectangle", {
x: 5,
y: 10,
width: 120,
height: 100,
});
API.setSelectedElements([rectangle]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe("text");
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle2.boundElements).toBeNull();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90);
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
expect(rectangle.height).toBe(166.66666666666669);
expect(textElement.fontSize).toBe(47.5);
});
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(h.elements.length).toBe(2);
mouse.select(rectangle);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 10, rectangle.y + 10);
mouse.up(rectangle.x + 10, rectangle.y + 10);
});
expect(h.elements.length).toBe(4);
const duplicatedRectangle = h.elements[0];
const duplicatedText = h
.elements[1] as ExcalidrawTextElementWithContainer;
const originalRect = h.elements[2];
const originalText = h.elements[3] as ExcalidrawTextElementWithContainer;
expect(originalRect.boundElements).toStrictEqual([
{ id: originalText.id, type: "text" },
]);
expect(originalText.containerId).toBe(originalRect.id);
expect(duplicatedRectangle.boundElements).toStrictEqual([
{ id: duplicatedText.id, type: "text" },
]);
expect(duplicatedText.containerId).toBe(duplicatedRectangle.id);
});
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
const originalRectX = rectangle.x;
const originalRectY = rectangle.y;
const originalTextX = text.x;
const originalTextY = text.y;
mouse.select(rectangle);
mouse.downAt(rectangle.x, rectangle.y);
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(90);
expect(text.y).toBe(90);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(rectangle.x).toBe(originalRectX);
expect(rectangle.y).toBe(originalRectY);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.x).toBe(originalTextX);
expect(text.y).toBe(originalTextY);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
});
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: " " } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
});
it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, {
target: { value: "Online whiteboard collaboration made easy" },
});
editor.blur();
expect(rectangle.height).toBe(135);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
expect(h.elements[0].boundElements).toEqual([]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
expect(rectangle.height).toBe(originalRectHeight);
});
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(215);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
});
//@todo fix this test later once measureText is mocked correctly
it.skip("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
});
});
});
+37 -136
View File
@@ -6,30 +6,20 @@ import {
isTestEnv,
} from "../utils";
import Scene from "../scene/Scene";
import {
isArrowElement,
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants";
import { isBoundToContainer, isTextElement } from "./typeChecks";
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextElement,
ExcalidrawTextContainer,
ExcalidrawLinearElement,
} from "./types";
import { AppState } from "../types";
import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
wrapText,
} from "./textElement";
import {
@@ -38,9 +28,17 @@ import {
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
import { getMaxContainerWidth } from "./newElement";
const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
const getTransform = (
width: number,
@@ -63,38 +61,6 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
};
const originalContainerCache: {
[id: ExcalidrawTextContainer["id"]]:
| {
height: ExcalidrawTextContainer["height"];
}
| undefined;
} = {};
export const updateOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
height: ExcalidrawTextContainer["height"],
) => {
const data =
originalContainerCache[id] || (originalContainerCache[id] = { height });
data.height = height;
return data;
};
export const resetOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
) => {
if (originalContainerCache[id]) {
delete originalContainerCache[id];
}
};
export const getOriginalContainerHeightFromCache = (
id: ExcalidrawTextContainer["id"],
) => {
return originalContainerCache[id]?.height ?? null;
};
export const textWysiwyg = ({
id,
onChange,
@@ -122,9 +88,6 @@ export const textWysiwyg = ({
updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement,
) => {
if (!editable.style.fontFamily || !editable.style.fontSize) {
return false;
}
const currentFont = editable.style.fontFamily.replace(/"/g, "");
if (
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
@@ -137,6 +100,7 @@ export const textWysiwyg = ({
}
return false;
};
let originalContainerHeight: number;
const updateWysiwygStyle = () => {
const appState = app.state;
@@ -151,7 +115,7 @@ export const textWysiwyg = ({
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x;
const coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
@@ -160,17 +124,8 @@ export const textWysiwyg = ({
const width = updatedTextElement.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height;
let height = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
editable,
@@ -179,52 +134,31 @@ export const textWysiwyg = ({
// using editor.style.height to get the accurate height of text editor
const editorHeight = Number(editable.style.height.slice(0, -2));
if (editorHeight > 0) {
textElementHeight = editorHeight;
height = editorHeight;
}
if (propertiesUpdated) {
originalContainerHeight = containerDims.height;
// update height of the editor after properties updated
textElementHeight = updatedTextElement.height;
height = updatedTextElement.height;
}
let originalContainerData;
if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
} else {
originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
}
if (!originalContainerHeight) {
originalContainerHeight = containerDims.height;
}
maxWidth = getMaxContainerWidth(container);
maxHeight = getMaxContainerHeight(container);
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
// autogrow container height if text exceeds
if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min(
textElementHeight - maxHeight,
approxLineHeight,
);
if (height > maxHeight) {
const diff = Math.min(height - maxHeight, approxLineHeight);
mutateElement(container, { height: containerDims.height + diff });
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
!isArrowElement(container) &&
containerDims.height > originalContainerData.height &&
textElementHeight < maxHeight
containerDims.height > originalContainerHeight &&
height < maxHeight
) {
const diff = Math.min(
maxHeight - textElementHeight,
approxLineHeight,
);
const diff = Math.min(maxHeight - height, approxLineHeight);
mutateElement(container, { height: containerDims.height - diff });
}
// Start pushing text upward until a diff of 30px (padding)
@@ -232,17 +166,11 @@ export const textWysiwyg = ({
else {
// vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
}
coordY = container.y + containerDims.height / 2 - height / 2;
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
}
}
}
@@ -276,19 +204,19 @@ 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;
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`,
height: `${textElementHeight}px`,
height: `${height}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(
width,
textElementHeight,
getTextElementAngle(updatedTextElement),
height,
angle,
appState,
maxWidth,
editorMaxHeight,
@@ -343,37 +271,10 @@ export const textWysiwyg = ({
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace,
overflowWrap: "break-word",
boxSizing: "content-box",
});
updateWysiwygStyle();
if (onChange) {
editable.onpaste = async (event) => {
const clipboardData = await parseClipboard(event, true);
if (!clipboardData.text) {
return;
}
const data = normalizeText(clipboardData.text);
if (!data) {
return;
}
const container = getContainerElement(element);
const font = getFontString({
fontSize: app.state.currentItemFontSize,
fontFamily: app.state.currentItemFontFamily,
});
if (container) {
const wrappedText = wrapText(
`${editable.value}${data}`,
font,
getMaxContainerWidth(container),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
}
};
editable.oninput = () => {
const updatedTextElement = Scene.getScene(element)?.getElement(
id,
@@ -584,7 +485,7 @@ export const textWysiwyg = ({
if (container) {
text = updateElement.text;
if (editable.value.trim()) {
if (editable.value) {
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, {
+5 -3
View File
@@ -4,7 +4,7 @@ import {
PointerType,
} from "./types";
import { getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math";
import { AppState, Zoom } from "../types";
import { isTextElement } from ".";
@@ -81,7 +81,7 @@ const generateTransformHandle = (
};
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
[x1, y1, x2, y2]: Bounds,
angle: number,
zoom: Zoom,
pointerType: PointerType,
@@ -97,6 +97,8 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1;
const height = y2 - y1;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
@@ -254,7 +256,7 @@ export const getTransformHandles = (
? DEFAULT_SPACING + 8
: DEFAULT_SPACING;
return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, true),
getElementAbsoluteCoords(element),
element.angle,
zoom,
pointerType,
+2 -64
View File
@@ -1,4 +1,3 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import {
ExcalidrawElement,
@@ -11,7 +10,6 @@ import {
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer,
RoundnessType,
} from "./types";
export const isGenericElement = (
@@ -62,12 +60,6 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLinearElement => {
return element != null && element.type === "arrow";
};
export const isLinearElementType = (
elementType: AppState["activeTool"]["type"],
): boolean => {
@@ -118,8 +110,7 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
element.type === "image")
);
};
@@ -148,59 +139,6 @@ export const isBoundToContainer = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElementWithContainer => {
return (
element !== null &&
"containerId" in element &&
element.containerId !== null &&
isTextElement(element)
element !== null && isTextElement(element) && element.containerId !== null
);
};
export const isUsingAdaptiveRadius = (type: string) => type === "rectangle";
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond";
export const canApplyRoundnessTypeToElement = (
roundnessType: RoundnessType,
element: ExcalidrawElement,
) => {
if (
(roundnessType === ROUNDNESS.ADAPTIVE_RADIUS ||
// if legacy roundness, it can be applied to elements that currently
// use adaptive radius
roundnessType === ROUNDNESS.LEGACY) &&
isUsingAdaptiveRadius(element.type)
) {
return true;
}
if (
roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS &&
isUsingProportionalRadius(element.type)
) {
return true;
}
return false;
};
export const getDefaultRoundnessTypeForElement = (
element: ExcalidrawElement,
) => {
if (
element.type === "arrow" ||
element.type === "line" ||
element.type === "diamond"
) {
return {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
};
}
if (element.type === "rectangle") {
return {
type: ROUNDNESS.ADAPTIVE_RADIUS,
};
}
return null;
};
+4 -17
View File
@@ -1,11 +1,5 @@
import { Point } from "../types";
import {
FONT_FAMILY,
ROUNDNESS,
TEXT_ALIGN,
THEME,
VERTICAL_ALIGN,
} from "../constants";
import { FONT_FAMILY, TEXT_ALIGN, THEME, VERTICAL_ALIGN } from "../constants";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid";
@@ -15,8 +9,7 @@ export type Theme = typeof THEME[keyof typeof THEME];
export type FontString = string & { _brand: "fontString" };
export type GroupId = string;
export type PointerType = "mouse" | "pen" | "touch";
export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeSharpness = "round" | "sharp";
export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
@@ -32,7 +25,7 @@ type _ExcalidrawElementBase = Readonly<{
fillStyle: FillStyle;
strokeWidth: number;
strokeStyle: StrokeStyle;
roundness: null | { type: RoundnessType; value?: number };
strokeSharpness: StrokeSharpness;
roughness: number;
opacity: number;
width: number;
@@ -148,8 +141,7 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
| ExcalidrawImageElement;
export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
@@ -174,11 +166,6 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
+3 -20
View File
@@ -242,12 +242,6 @@ class Collab extends PureComponent<Props, CollabState> {
);
}
} catch (error: any) {
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
console.error(error);
}
};
@@ -316,27 +310,16 @@ class Collab extends PureComponent<Props, CollabState> {
}
};
private fetchImageFilesFromFirebase = async (opts: {
private fetchImageFilesFromFirebase = async (scene: {
elements: readonly ExcalidrawElement[];
/**
* Indicates whether to fetch files that are errored or pending and older
* than 10 seconds.
*
* Use this as a machanism to fetch files which may be ok but for some
* reason their status was not updated correctly.
*/
forceFetchFiles?: boolean;
}) => {
const unfetchedImages = opts.elements
const unfetchedImages = scene.elements
.filter((element) => {
return (
isInitializedImageElement(element) &&
!this.fileManager.isFileHandled(element.fileId) &&
!element.isDeleted &&
(opts.forceFetchFiles
? element.status !== "pending" ||
Date.now() - element.updated > 10000
: element.status === "saved")
element.status === "saved"
);
})
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
@@ -1,17 +0,0 @@
import { isExcalidrawPlusSignedUser } from "../../constants";
export const ExcalidrawPlusAppLink = () => {
if (!isExcalidrawPlusSignedUser) {
return null;
}
return (
<a
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
className="plus-button"
>
Go to Excalidraw+
</a>
);
};
-1
View File
@@ -195,7 +195,6 @@ export const encodeFilesForUpload = async ({
id,
mimeType: fileData.mimeType,
created: Date.now(),
lastRetrieved: Date.now(),
},
});
+8 -32
View File
@@ -10,7 +10,7 @@
* (localStorage, indexedDB).
*/
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { createStore, keys, del, getMany, set } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types";
@@ -25,21 +25,12 @@ const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
// if image is unused (not on canvas) & is older than 1 day, delete it
// from storage. We check `lastRetrieved` we care about the last time
// the image was used (loaded on canvas), not when it was initially
// created.
if (
(!imageData.lastRetrieved ||
Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
!opts.currentFileIds.includes(id as FileId)
) {
del(id, filesStore);
}
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
});
}
};
}
@@ -120,33 +111,18 @@ export class LocalData {
static fileStorage = new LocalFileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => {
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
const filesToSave: [FileId, BinaryFileData][] = [];
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
const _data: BinaryFileData = {
...data,
lastRetrieved: Date.now(),
};
filesToSave.push([id, _data]);
loadedFiles.push(_data);
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
try {
// save loaded files back to storage with updated `lastRetrieved`
setMany(filesToSave, filesStore);
} catch (error) {
console.warn(error);
}
return { loadedFiles, erroredFiles };
},
);
-1
View File
@@ -330,7 +330,6 @@ export const loadFilesFromFirebase = async (
id,
dataURL,
created: metadata?.created || Date.now(),
lastRetrieved: metadata?.created || Date.now(),
});
} else {
erroredFiles.set(id, true);
+3 -2
View File
@@ -4,8 +4,9 @@
&.theme--dark {
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.layer-ui__wrapper .layer-ui__wrapper__footer-center {
justify-content: flex-end;
.layer-ui__wrapper__footer-center {
display: flex;
justify-content: space-between;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
+45 -12
View File
@@ -7,6 +7,7 @@ import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import {
APP_NAME,
COOKIES,
EVENT,
THEME,
TITLE_TIMEOUT,
@@ -21,7 +22,7 @@ import {
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index";
import { Excalidraw, defaultLang } from "../packages/excalidraw/index";
import {
AppState,
LibraryItems,
@@ -49,6 +50,7 @@ import Collab, {
collabDialogShownAtom,
isCollaboratingAtom,
} from "./collab/Collab";
import { LanguageList } from "./components/LanguageList";
import {
exportToBackend,
getCollaborationLinkData,
@@ -77,12 +79,15 @@ import { atom, Provider, useAtom } from "jotai";
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
import { EncryptedIcon } from "./components/EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink";
import EncryptedIcon from "../components/EncryptedIcon";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
@@ -280,7 +285,6 @@ const ExcalidrawWrapper = () => {
collabAPI
.fetchImageFilesFromFirebase({
elements: data.scene.elements,
forceFetchFiles: true,
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
@@ -572,6 +576,41 @@ const ExcalidrawWrapper = () => {
}
};
const renderFooter = (isMobile: boolean) => {
const renderLanguageList = () => <LanguageList />;
if (isMobile) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
<div style={{ marginBottom: ".5rem", fontSize: "0.75rem" }}>
{t("labels.language")}
</div>
<div style={{ padding: "0 0.625rem" }}>{renderLanguageList()}</div>
</div>
);
}
return (
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
{isExcalidrawPlusSignedUser && (
<a
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
className="plus-button"
>
Go to Excalidraw+
</a>
)}
<EncryptedIcon />
</div>
);
};
const renderCustomStats = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
@@ -632,6 +671,7 @@ const ExcalidrawWrapper = () => {
},
},
}}
renderFooter={renderFooter}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
@@ -639,14 +679,7 @@ const ExcalidrawWrapper = () => {
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
>
<Footer>
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
<ExcalidrawPlusAppLink />
<EncryptedIcon />
</div>
</Footer>
</Excalidraw>
/>
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
+1 -1
View File
@@ -1,4 +1,4 @@
import { register as registerServiceWorker } from "../serviceWorkerRegistration";
import { register as registerServiceWorker } from "../serviceWorker";
import { EVENT } from "../constants";
// On Apple mobile devices add the proprietary app icon and splashscreen markup.
-2
View File
@@ -2,8 +2,6 @@
interface Document {
fonts?: {
ready?: Promise<void>;
check?: (font: string, text?: string) => boolean;
load?: (font: string, text?: string) => Promise<FontFace[]>;
addEventListener?(
type: "loading" | "loadingdone" | "loadingerror",
listener: (this: Document, ev: Event) => any,
+4 -9
View File
@@ -24,7 +24,7 @@ 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: "gl-ES ", label: "Galego" },
{ code: "he-IL", label: "עברית", rtl: true },
{ code: "hi-IN", label: "हिन्दी" },
{ code: "hu-HU", label: "Magyar" },
@@ -90,14 +90,9 @@ export const setLanguage = async (lang: Language) => {
if (lang.code.startsWith(TEST_LANG_CODE)) {
currentLangData = {};
} else {
try {
currentLangData = await import(
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
);
} catch (error: any) {
console.error(`Failed to load language ${lang.code}:`, error.message);
currentLangData = fallbackLangData;
}
currentLangData = await import(
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
);
}
};
-13
View File
@@ -29,8 +29,6 @@ export const KEYS = {
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ARROW_UP: "ArrowUp",
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
BACKSPACE: "Backspace",
ALT: "Alt",
CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey",
@@ -65,17 +63,6 @@ export const KEYS = {
Y: "y",
Z: "z",
K: "k",
0: "0",
1: "1",
2: "2",
3: "3",
4: "4",
5: "5",
6: "6",
7: "7",
8: "8",
9: "9",
} as const;
export type Key = keyof typeof KEYS;
+7 -18
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "لصق",
"pasteAsPlaintext": "",
"pasteCharts": "لصق الرسوم البيانية",
"selectAll": "تحديد الكل",
"multiSelect": "إضافة عنصر للتحديد",
@@ -72,7 +71,7 @@
"layers": "الطبقات",
"actions": "الإجراءات",
"language": "اللغة",
"liveCollaboration": "",
"liveCollaboration": "بدء المشاركة الحية",
"duplicateSelection": "تكرار",
"untitled": "غير معنون",
"name": "الاسم",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
"exportJSON": "صدر الملف",
"exportImage": "",
"export": "",
"exportImage": "إحفظ كصورة",
"export": "تصدير",
"exportToPng": "تصدير بصيغة PNG",
"exportToSvg": "تصدير بصيغة SVG",
"copyToClipboard": "نسخ إلى الحافظة",
@@ -145,7 +144,7 @@
"scale": "مقاس",
"save": "احفظ للملف الحالي",
"saveAs": "حفظ كـ",
"load": "",
"load": "تحميل",
"getShareableLink": "احصل على رابط المشاركة",
"close": "غلق",
"selectLanguage": "اختر اللغة",
@@ -236,7 +235,7 @@
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
"resizeImage": "يمكنك تغيير الحجم بحرية بالضغط بأستمرار على SHIFT،\nاضغط بأستمرار على ALT أيضا لتغيير الحجم من المركز",
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
"lineEditor_info": "",
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
@@ -311,9 +310,7 @@
"view": "عرض",
"zoomToFit": "تكبير للملائمة",
"zoomToSelection": "تكبير للعنصر المحدد",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "مسح اللوحة"
@@ -394,8 +391,7 @@
"fileSaved": "تم حفظ الملف.",
"fileSavedToFilename": "حفظ باسم {filename}",
"canvas": "لوحة الرسم",
"selection": "العنصر المحدد",
"pasteAsSingleElement": ""
"selection": "العنصر المحدد"
},
"colors": {
"ffffff": "أبيض",
@@ -443,12 +439,5 @@
"5c940d": "ليموني 9",
"e67700": "أصفر 9",
"d9480f": "برتقالي 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+6 -17
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Постави",
"pasteAsPlaintext": "",
"pasteCharts": "Постави графики",
"selectAll": "Маркирай всичко",
"multiSelect": "Добави елемент към селекция",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Нулиране на платно",
"exportJSON": "",
"exportImage": "",
"export": "",
"exportImage": "Запиши като изображение",
"export": "Експортиране",
"exportToPng": "Изнасяне в PNG",
"exportToSvg": "Изнасяне в SVG",
"copyToClipboard": "Копиране в клипборда",
@@ -145,7 +144,7 @@
"scale": "Мащаб",
"save": "",
"saveAs": "Запиши като",
"load": "",
"load": "Зареждане",
"getShareableLink": "Получаване на връзка за споделяне",
"close": "Затвори",
"selectLanguage": "Избор на език",
@@ -236,7 +235,7 @@
"resize": "Може да ограничите при преоразмеряване като задържите SHIFT,\nзадръжте ALT за преоразмерите през центъра",
"resizeImage": "",
"rotate": "Можете да ограничите ъглите, като държите SHIFT, докато се въртите",
"lineEditor_info": "",
"lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките",
"lineEditor_pointSelected": "Натиснете Delete за да изтриете точка(и), CtrlOrCmd+D за дуплициране, или извлачете за да преместите",
"lineEditor_nothingSelected": "",
"placeImage": "",
@@ -311,9 +310,7 @@
"view": "Преглед",
"zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "Приближи селекцията",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""
@@ -394,8 +391,7 @@
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": "",
"pasteAsSingleElement": ""
"selection": ""
},
"colors": {
"ffffff": "",
@@ -443,12 +439,5 @@
"5c940d": "",
"e67700": "",
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+8 -19
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "পেস্ট করুন",
"pasteAsPlaintext": "",
"pasteCharts": "চার্ট পেস্ট করুন",
"selectAll": "সবটা সিলেক্ট করুন",
"multiSelect": "একাধিক সিলেক্ট করুন",
@@ -72,7 +71,7 @@
"layers": "মাত্রা",
"actions": "ক্রিয়া",
"language": "ভাষা",
"liveCollaboration": "",
"liveCollaboration": "যুগ্ম কার্য",
"duplicateSelection": "সদৃশ সিলেক্ট",
"untitled": "অনামী",
"name": "নাম",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "ক্যানভাস সাফ করুন",
"exportJSON": "জেসন নিবদ্ধ করুন",
"exportImage": "",
"export": "",
"exportImage": "চিত্র নিবদ্ধ করুন",
"export": "নিবদ্ধ",
"exportToPng": "পীএনজী ছবির মতন নিবদ্ধ করুন",
"exportToSvg": "এসভীজী ছবির মতন নিবদ্ধ করুন",
"copyToClipboard": "ক্লিপবোর্ডে কপি করুন",
@@ -145,7 +144,7 @@
"scale": "মাপ",
"save": "জমা করুন",
"saveAs": "অন্যভাবে জমা করুন",
"load": "",
"load": "লোড করুন",
"getShareableLink": "ভাগযোগ্য লিঙ্ক পান",
"close": "বন্ধ করুন",
"selectLanguage": "ভাষা চিহ্নিত করুন",
@@ -215,7 +214,7 @@
"text": "লেখা",
"library": "সংগ্রহ",
"lock": "আঁকার পরে নির্বাচিত টুল সক্রিয় রাখুন",
"penMode": "",
"penMode": "পিঞ্চ-জুম প্রতিরোধ করুন এবং শুধুমাত্র কলম থেকে ইনপুট গ্রহণ করুন",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন"
},
@@ -236,7 +235,7 @@
"resize": "আপনি আকার পরিবর্তন করার সময় শিফ্ট ধরে রেখে অনুপাতকে সীমাবদ্ধ করতে পারেন,\nকেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরে রাখুন",
"resizeImage": "আপনি শিফ্ট ধরে রেখে অবাধে আকার পরিবর্তন করতে পারেন, কেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরুন",
"rotate": "আপনি ঘোরানোর সময় শিফ্ট ধরে রেখে কোণগুলিকে সীমাবদ্ধ করতে পারেন",
"lineEditor_info": "",
"lineEditor_info": "পয়েন্ট সম্পাদনা করতে ডাবল-ক্লিক করুন বা এন্টার টিপুন",
"lineEditor_pointSelected": "বিন্দু(গুলি) মুছতে ডিলিট টিপুন, কন্ট্রোল/কম্যান্ড যোগে ডি টিপুন নকল করতে অথবা সরানোর জন্য টানুন",
"lineEditor_nothingSelected": "সম্পাদনা করার জন্য একটি বিন্দু নির্বাচন করুন (একাধিক নির্বাচন করতে শিফ্ট ধরে রাখুন),\nঅথবা অল্ট ধরে রাখুন এবং নতুন বিন্দু যোগ করতে ক্লিক করুন",
"placeImage": "ছবিটি স্থাপন করতে ক্লিক করুন, অথবা নিজে আকার সেট করতে ক্লিক করুন এবং টেনে আনুন",
@@ -311,9 +310,7 @@
"view": "",
"zoomToFit": "",
"zoomToSelection": "",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""
@@ -394,8 +391,7 @@
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": "বাছাই",
"pasteAsSingleElement": ""
"selection": "বাছাই"
},
"colors": {
"ffffff": "সাদা",
@@ -443,12 +439,5 @@
"5c940d": "",
"e67700": "",
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+8 -19
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Enganxa",
"pasteAsPlaintext": "",
"pasteCharts": "Enganxa els diagrames",
"selectAll": "Selecciona-ho tot",
"multiSelect": "Afegeix un element a la selecció",
@@ -72,7 +71,7 @@
"layers": "Capes",
"actions": "Accions",
"language": "Llengua",
"liveCollaboration": "",
"liveCollaboration": "Col·laboració en directe",
"duplicateSelection": "Duplica",
"untitled": "Sense títol",
"name": "Nom",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Neteja el llenç",
"exportJSON": "Exporta a un fitxer",
"exportImage": "",
"export": "",
"exportImage": "Desa com a imatge",
"export": "Exporta",
"exportToPng": "Exporta a PNG",
"exportToSvg": "Exporta a SNG",
"copyToClipboard": "Copia al porta-retalls",
@@ -145,7 +144,7 @@
"scale": "Escala",
"save": "Desa al fitxer actual",
"saveAs": "Anomena i desa",
"load": "",
"load": "Carrega",
"getShareableLink": "Obté l'enllaç per a compartir",
"close": "Tanca",
"selectLanguage": "Trieu la llengua",
@@ -215,7 +214,7 @@
"text": "Text",
"library": "Biblioteca",
"lock": "Mantenir activa l'eina seleccionada desprès de dibuixar",
"penMode": "",
"penMode": "Evita el zoom i accepta solament el dibuix lliure amb bolígraf",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador"
},
@@ -236,7 +235,7 @@
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
"resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT",
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
"lineEditor_info": "",
"lineEditor_info": "Fes doble clic o premi Enter per editar punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts",
"placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment",
@@ -311,9 +310,7 @@
"view": "Visualització",
"zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": "Blocar/desblocar la selecció",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Blocar/desblocar la selecció"
},
"clearCanvasDialog": {
"title": "Neteja el llenç"
@@ -394,8 +391,7 @@
"fileSaved": "S'ha desat el fitxer.",
"fileSavedToFilename": "S'ha desat a {filename}",
"canvas": "el llenç",
"selection": "la selecció",
"pasteAsSingleElement": ""
"selection": "la selecció"
},
"colors": {
"ffffff": "Blanc",
@@ -443,12 +439,5 @@
"5c940d": "Llima 9",
"e67700": "Groc 9",
"d9480f": "Taronja 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+50 -61
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Vložit",
"pasteAsPlaintext": "",
"pasteCharts": "Vložit grafy",
"selectAll": "Vybrat vše",
"multiSelect": "Přidat prvek do výběru",
@@ -61,18 +60,18 @@
"center": "Na střed",
"right": "Vpravo",
"extraBold": "Extra tlustý",
"architect": "Architekt",
"artist": "Umělec",
"architect": "",
"artist": "",
"cartoonist": "",
"fileTitle": "Název souboru",
"colorPicker": "Výběr barvy",
"canvasColors": "Použito na plátně",
"canvasColors": "",
"canvasBackground": "Pozadí plátna",
"drawingCanvas": "Kreslicí plátno",
"drawingCanvas": "",
"layers": "Vrstvy",
"actions": "Akce",
"language": "Jazyk",
"liveCollaboration": "Živá spolupráce...",
"liveCollaboration": "Živá spolupráce",
"duplicateSelection": "Duplikovat",
"untitled": "Bez názvu",
"name": "Název",
@@ -99,57 +98,57 @@
"flipHorizontal": "Převrátit vodorovně",
"flipVertical": "Převrátit svisle",
"viewMode": "Náhled",
"toggleExportColorScheme": "Přepnout exportování barevného schématu",
"toggleExportColorScheme": "",
"share": "Sdílet",
"showStroke": "Zobrazit výběr barvy",
"showBackground": "Zobrazit výběr barev pozadí",
"showStroke": "",
"showBackground": "",
"toggleTheme": "Přepnout tmavý řežim",
"personalLib": "Osobní knihovna",
"excalidrawLib": "Exkalidraw knihovna",
"decreaseFontSize": "Zmenšit písmo",
"increaseFontSize": "Zvětšit písmo",
"unbindText": "Zrušit vazbu textu",
"bindText": "Vázat text s kontejnerem",
"personalLib": "",
"excalidrawLib": "",
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "Upravit odkaz",
"create": "Vytvořit odkaz",
"label": "Odkaz"
"edit": "",
"create": "",
"label": ""
},
"lineEditor": {
"edit": "Upravit čáru",
"exit": "Ukončit editor řádků"
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "Uzamknout",
"unlock": "Odemknout",
"lockAll": "Uzamknout vše",
"unlockAll": "Odemknout vše"
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
},
"statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený"
"statusPublished": "",
"sidebarLock": ""
},
"library": {
"noItems": "Dosud neexistují žádné položky...",
"hint_emptyLibrary": "Vyberte položku na plátně a přidejte ji sem nebo nainstalujte knihovnu z veřejného úložiště níže.",
"hint_emptyPrivateLibrary": "Vyberte položku na plátně a přidejte ji sem."
"noItems": "",
"hint_emptyLibrary": "",
"hint_emptyPrivateLibrary": ""
},
"buttons": {
"clearReset": "Resetovat plátno",
"exportJSON": "Exportovat do souboru",
"exportImage": "Exportovat obrázek...",
"export": "Uložit jako...",
"clearReset": "",
"exportJSON": "",
"exportImage": "",
"export": "Exportovat",
"exportToPng": "Exportovat do PNG",
"exportToSvg": "Exportovat do SVG",
"copyToClipboard": "Kopírovat do schránky",
"copyPngToClipboard": "Kopírovat PNG do schránky",
"scale": "Měřítko",
"save": "Uložit do aktuálního souboru",
"save": "",
"saveAs": "Uložit jako",
"load": "Otevřít",
"load": "Nahrát",
"getShareableLink": "Získat odkaz pro sdílení",
"close": "Zavřít",
"selectLanguage": "Zvolit jazyk",
"scrollBackToContent": "Přejít zpět na obsah",
"scrollBackToContent": "",
"zoomIn": "Přiblížit",
"zoomOut": "Oddálit",
"resetZoom": "Resetovat přiblížení",
@@ -158,7 +157,7 @@
"edit": "Upravit",
"undo": "Zpět",
"redo": "Znovu",
"resetLibrary": "Obnovit knihovnu",
"resetLibrary": "",
"createNewRoom": "Vytvořit novou místnost",
"fullScreen": "Celá obrazovka",
"darkMode": "Tmavý režim",
@@ -177,17 +176,17 @@
"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",
"importBackendFailed": "Import z backendu se nezdařil.",
"cannotExportEmptyCanvas": "Nelze exportovat prázdné plátno.",
"couldNotCopyToClipboard": "Nelze zkopírovat do schránky.",
"decryptFailed": "Nelze dešifrovat data.",
"uploadedSecurly": "Nahrávání je zabezpečeno koncovým šifrováním, což znamená, že server Excalidraw ani třetí strany nemohou obsah přečíst.",
"loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
"collabStopOverridePrompt": "",
"errorAddingToLibrary": "Položku nelze přidat do knihovny",
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
"confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?",
"imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
@@ -256,8 +255,8 @@
"clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "",
"trackedToSentry_pre": "Chyba identifikátoru ",
"trackedToSentry_post": " byl zaznamenán v našem systému.",
"trackedToSentry_pre": "",
"trackedToSentry_post": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
@@ -311,9 +310,7 @@
"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",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Zamknout/odemknout výběr"
},
"clearCanvasDialog": {
"title": "Vymazat plátno"
@@ -394,8 +391,7 @@
"fileSaved": "Soubor byl uložen.",
"fileSavedToFilename": "Uloženo do {filename}",
"canvas": "plátno",
"selection": "výběr",
"pasteAsSingleElement": ""
"selection": "výběr"
},
"colors": {
"ffffff": "Bílá",
@@ -443,12 +439,5 @@
"5c940d": "Limetková 9",
"e67700": "Žlutá 9",
"d9480f": "Oranzova"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+7 -18
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Indsæt",
"pasteAsPlaintext": "",
"pasteCharts": "Indsæt diagrammer",
"selectAll": "Marker alle",
"multiSelect": "Tilføj element til markering",
@@ -70,12 +69,12 @@
"canvasBackground": "Lærredsbaggrund",
"drawingCanvas": "Tegnelærred",
"layers": "Lag",
"actions": "Handlinger",
"actions": "",
"language": "Sprog",
"liveCollaboration": "",
"liveCollaboration": "Direkte samarbejde",
"duplicateSelection": "",
"untitled": "Unavngivet",
"name": "Navn",
"untitled": "",
"name": "",
"yourName": "Dit navn",
"madeWithExcalidraw": "Fremstillet med Excalidraw",
"group": "",
@@ -145,7 +144,7 @@
"scale": "",
"save": "",
"saveAs": "Gem som",
"load": "",
"load": "Indlæs",
"getShareableLink": "Lav et delbart link",
"close": "Luk",
"selectLanguage": "Vælg sprog",
@@ -311,9 +310,7 @@
"view": "",
"zoomToFit": "",
"zoomToSelection": "",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""
@@ -394,8 +391,7 @@
"fileSaved": "Fil gemt.",
"fileSavedToFilename": "Gemt som {filename}",
"canvas": "canvas",
"selection": "markering",
"pasteAsSingleElement": ""
"selection": "markering"
},
"colors": {
"ffffff": "",
@@ -443,12 +439,5 @@
"5c940d": "",
"e67700": "",
"d9480f": ""
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+8 -19
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Einfügen",
"pasteAsPlaintext": "Als reinen Text einfügen",
"pasteCharts": "Diagramme einfügen",
"selectAll": "Alle auswählen",
"multiSelect": "Element zur Auswahl hinzufügen",
@@ -72,7 +71,7 @@
"layers": "Ebenen",
"actions": "Aktionen",
"language": "Sprache",
"liveCollaboration": "Live-Zusammenarbeit...",
"liveCollaboration": "Live-Zusammenarbeit",
"duplicateSelection": "Duplizieren",
"untitled": "Unbenannt",
"name": "Name",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
"exportJSON": "In Datei exportieren",
"exportImage": "Exportiere Bild...",
"export": "Speichern als...",
"exportImage": "Als Bild speichern",
"export": "Exportieren",
"exportToPng": "Als PNG exportieren",
"exportToSvg": "Als SVG exportieren",
"copyToClipboard": "In Zwischenablage kopieren",
@@ -145,7 +144,7 @@
"scale": "Skalierung",
"save": "In aktueller Datei speichern",
"saveAs": "Speichern unter",
"load": "Öffnen",
"load": "Laden",
"getShareableLink": "Teilbaren Link erhalten",
"close": "Schließen",
"selectLanguage": "Sprache auswählen",
@@ -215,7 +214,7 @@
"text": "Text",
"library": "Bibliothek",
"lock": "Ausgewähltes Werkzeug nach Zeichnen aktiv lassen",
"penMode": "Stift-Modus - Berührung verhindern",
"penMode": "Verhindere Pinch-Zoom und akzeptiere Eingabe nur vom Stift",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer"
},
@@ -236,7 +235,7 @@
"resize": "Du kannst die Proportionen einschränken, indem du SHIFT während der Größenänderung gedrückt hältst. Halte ALT gedrückt, um die Größe vom Zentrum aus zu ändern",
"resizeImage": "Du kannst die Größe frei ändern, indem du SHIFT gedrückt hältst; halte ALT, um die Größe vom Zentrum aus zu ändern",
"rotate": "Du kannst Winkel einschränken, indem du SHIFT während der Drehung gedrückt hältst",
"lineEditor_info": "CtrlOrCmd halten und Doppelklick oder CtrlOrCmd + Eingabe drücken, um Punkte zu bearbeiten",
"lineEditor_info": "Doppelklicken oder Eingabetaste drücken, um Punkte zu bearbeiten",
"lineEditor_pointSelected": "Drücke Löschen, um Punkt(e) zu entfernen, CtrlOrCmd+D zum Duplizieren oder ziehe zum Verschieben",
"lineEditor_nothingSelected": "Wähle einen zu bearbeitenden Punkt (halte SHIFT gedrückt um mehrere Punkte auszuwählen),\noder halte Alt gedrückt und klicke um neue Punkte hinzuzufügen",
"placeImage": "Klicken, um das Bild zu platzieren oder klicken und ziehen um seine Größe manuell zu setzen",
@@ -311,9 +310,7 @@
"view": "Ansicht",
"zoomToFit": "Zoomen um alle Elemente einzupassen",
"zoomToSelection": "Auf Auswahl zoomen",
"toggleElementLock": "Auswahl sperren/entsperren",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Auswahl sperren/entsperren"
},
"clearCanvasDialog": {
"title": "Zeichenfläche löschen"
@@ -394,8 +391,7 @@
"fileSaved": "Datei gespeichert.",
"fileSavedToFilename": "Als {filename} gespeichert",
"canvas": "Zeichenfläche",
"selection": "Auswahl",
"pasteAsSingleElement": "Verwende {{shortcut}} , um als einzelnes Element\neinzufügen oder in einen existierenden Texteditor einzufügen"
"selection": "Auswahl"
},
"colors": {
"ffffff": "Weiß",
@@ -443,12 +439,5 @@
"5c940d": "Hellgrün 9",
"e67700": "Gelb 9",
"d9480f": "Orange 9"
},
"welcomeScreen": {
"data": "Alle Daten werden lokal in Deinem Browser gespeichert.",
"switchToPlusApp": "Möchtest du stattdessen zu Excalidraw+ gehen?",
"menuHints": "Exportieren, Einstellungen, Sprachen, ...",
"toolbarHints": "Wähle ein Werkzeug & beginne zu zeichnen!",
"helpHints": "Kurzbefehle & Hilfe"
}
}
+7 -18
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Επικόλληση",
"pasteAsPlaintext": "",
"pasteCharts": "Επικόλληση γραφημάτων",
"selectAll": "Επιλογή όλων",
"multiSelect": "Προσθέστε το στοιχείο στην επιλογή",
@@ -72,7 +71,7 @@
"layers": "Στρώματα",
"actions": "Ενέργειες",
"language": "Γλώσσα",
"liveCollaboration": "",
"liveCollaboration": "Ζωντανή συνεργασία",
"duplicateSelection": "Δημιουργία αντιγράφου",
"untitled": "Χωρίς τίτλο",
"name": "Όνομα",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Επαναφορά του καμβά",
"exportJSON": "Εξαγωγή σε αρχείο",
"exportImage": "",
"export": "",
"exportImage": "Αποθήκευση ως εικόνα",
"export": "Εξαγωγή",
"exportToPng": "Εξαγωγή σε PNG",
"exportToSvg": "Εξαγωγή σε SVG",
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
@@ -145,7 +144,7 @@
"scale": "Κλίμακα",
"save": "Αποθήκευση στο τρέχον αρχείο",
"saveAs": "Αποθήκευση ως",
"load": "",
"load": "Άνοιγμα",
"getShareableLink": "Δημόσιος σύνδεσμος",
"close": "Κλείσιμο",
"selectLanguage": "Επιλογή γλώσσας",
@@ -236,7 +235,7 @@
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
"resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
"lineEditor_info": "",
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
"lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
"placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
@@ -311,9 +310,7 @@
"view": "Προβολή",
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
"zoomToSelection": "Ζουμ στην επιλογή",
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής"
},
"clearCanvasDialog": {
"title": "Καθαρισμός καμβά"
@@ -394,8 +391,7 @@
"fileSaved": "Το αρχείο αποθηκεύτηκε.",
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}",
"canvas": "καμβάς",
"selection": "επιλογή",
"pasteAsSingleElement": ""
"selection": "επιλογή"
},
"colors": {
"ffffff": "Λευκό",
@@ -443,12 +439,5 @@
"5c940d": "Πρασινοκίτρινο 9",
"e67700": "Κίτρινο 9",
"d9480f": "Πορτοκαλί 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+4 -10
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Paste",
"pasteAsPlaintext": "Paste as plaintext",
"pasteCharts": "Paste charts",
"selectAll": "Select all",
"multiSelect": "Add element to selection",
@@ -202,9 +201,7 @@
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "Invalid SVG.",
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
"importLibraryError": "Couldn't load library"
},
"toolBar": {
"selection": "Selection",
@@ -239,7 +236,7 @@
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
"lineEditor_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually",
@@ -314,9 +311,7 @@
"view": "View",
"zoomToFit": "Zoom to fit all elements",
"zoomToSelection": "Zoom to selection",
"toggleElementLock": "Lock/unlock selection",
"movePageUpDown": "Move page up/down",
"movePageLeftRight": "Move page left/right"
"toggleElementLock": "Lock/unlock selection"
},
"clearCanvasDialog": {
"title": "Clear canvas"
@@ -397,8 +392,7 @@
"fileSaved": "File saved.",
"fileSavedToFilename": "Saved to {filename}",
"canvas": "canvas",
"selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
"selection": "selection"
},
"colors": {
"ffffff": "White",
+16 -27
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Pegar",
"pasteAsPlaintext": "Pegar como texto sin formato",
"pasteCharts": "Pegar gráficos",
"selectAll": "Seleccionar todo",
"multiSelect": "Añadir elemento a la selección",
@@ -72,7 +71,7 @@
"layers": "Capas",
"actions": "Acciones",
"language": "Idioma",
"liveCollaboration": "Colaboración en directo...",
"liveCollaboration": "Colaboración en directo",
"duplicateSelection": "Duplicar",
"untitled": "Sin título",
"name": "Nombre",
@@ -116,8 +115,8 @@
"label": "Enlace"
},
"lineEditor": {
"edit": "Editar línea",
"exit": "Salir del editor en línea"
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "Bloquear",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
"exportJSON": "Exportar a archivo",
"exportImage": "Exportar imagen...",
"export": "Guardar en...",
"exportImage": "Guardar como imagen",
"export": "Exportar",
"exportToPng": "Exportar a PNG",
"exportToSvg": "Exportar a SVG",
"copyToClipboard": "Copiar al portapapeles",
@@ -145,7 +144,7 @@
"scale": "Escalar",
"save": "Guardar en archivo actual",
"saveAs": "Guardar como",
"load": "Abrir",
"load": "Cargar",
"getShareableLink": "Obtener enlace para compartir",
"close": "Cerrar",
"selectLanguage": "Elegir idioma",
@@ -215,7 +214,7 @@
"text": "Texto",
"library": "Biblioteca",
"lock": "Mantener la herramienta seleccionada activa después de dibujar",
"penMode": "Modo Lápiz - previene toque",
"penMode": "Evitar el zoom de pellizco y aceptar la entrada libre sólo desde el lápiz",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar"
},
@@ -236,10 +235,10 @@
"resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro",
"resizeImage": "Puede redimensionar libremente pulsando SHIFT,\npulse ALT para redimensionar desde el centro",
"rotate": "Puedes restringir los ángulos manteniendo presionado SHIFT mientras giras",
"lineEditor_info": "Mantenga pulsado CtrlOrCmd y haga doble click o presione CtrlOrCmd + Enter para editar puntos",
"lineEditor_info": "Doble clic o pulse Enter para editar puntos",
"lineEditor_pointSelected": "Presione Suprimir para eliminar el/los punto(s), CtrlOrCmd+D para duplicarlo, o arrástrelo para moverlo",
"lineEditor_nothingSelected": "Seleccione un punto a editar (mantenga MAYÚSCULAS para seleccionar múltiples),\no mantenga pulsado Alt y haga click para añadir nuevos puntos",
"placeImage": "Haga clic para colocar la imagen o haga click y arrastre para establecer su tamaño manualmente",
"lineEditor_nothingSelected": "Seleccione un punto a editar (mantenga MAYÚSCULAS para seleccionar múltiples),\no mantenga pulsado Alt y haga clic para añadir nuevos puntos",
"placeImage": "Haga clic para colocar la imagen o haga clic y arrastre para establecer su tamaño manualmente",
"publishLibrary": "Publica tu propia biblioteca",
"bindTextToElement": "Presione Entrar para agregar",
"deepBoxSelect": "Mantén CtrlOrCmd para seleccionar en profundidad, y para evitar arrastrar",
@@ -289,7 +288,7 @@
},
"helpDialog": {
"blog": "Lea nuestro blog",
"click": "click",
"click": "clic",
"deepSelect": "Selección profunda",
"deepBoxSelect": "Seleccione en profundidad dentro de la caja, y evite arrastrar",
"curvedArrow": "Flecha curva",
@@ -302,7 +301,7 @@
"github": "¿Ha encontrado un problema? Envíelo",
"howto": "Siga nuestras guías",
"or": "o",
"preventBinding": "Evitar enlace de flechas",
"preventBinding": "Evitar yuxtaposición de flechas",
"tools": "Herramientas",
"shortcuts": "Atajos del teclado",
"textFinish": "Finalizar edición (editor de texto)",
@@ -311,9 +310,7 @@
"view": "Vista",
"zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Zoom a la selección",
"toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "Mover página hacia arriba/abajo",
"movePageLeftRight": "Mover página hacia la izquierda/derecha"
"toggleElementLock": "Bloquear/desbloquear selección"
},
"clearCanvasDialog": {
"title": "Borrar lienzo"
@@ -331,7 +328,7 @@
"authorName": "Nombre o nombre de usuario",
"libraryName": "Nombre de tu biblioteca",
"libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso",
"githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión",
"githubHandle": "GitHub maneja (opcional), así que puede editar la biblioteca una vez enviada para su revisión",
"twitterHandle": "Nombre de usuario de Twitter (opcional), así que sabemos a quién acreditar cuando se promociona en Twitter",
"website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)"
},
@@ -382,7 +379,7 @@
"title": "Estadísticas para nerds",
"total": "Total",
"version": "Versión",
"versionCopy": "Click para copiar",
"versionCopy": "Clic para copiar",
"versionNotAvailable": "Versión no disponible",
"width": "Ancho"
},
@@ -394,8 +391,7 @@
"fileSaved": "Archivo guardado.",
"fileSavedToFilename": "Guardado en {filename}",
"canvas": "lienzo",
"selection": "selección",
"pasteAsSingleElement": "Usa {{shortcut}} para pegar como un solo elemento,\no pegar en un editor de texto existente"
"selection": "selección"
},
"colors": {
"ffffff": "Blanco",
@@ -443,12 +439,5 @@
"5c940d": "Lima 9",
"e67700": "Amarillo 9",
"d9480f": "Naranja 9"
},
"welcomeScreen": {
"data": "Toda su información es guardada localmente en su navegador.",
"switchToPlusApp": "¿Quieres ir a Excalidraw+ en su lugar?",
"menuHints": "Exportar, preferencias, idiomas, ...",
"toolbarHints": "¡Escoge una herramienta & Empiece a dibujar!",
"helpHints": "Atajos & ayuda"
}
}
+10 -21
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Itsatsi",
"pasteAsPlaintext": "",
"pasteCharts": "Itsatsi grafikoak",
"selectAll": "Hautatu dena",
"multiSelect": "Gehitu elementua hautapenera",
@@ -72,7 +71,7 @@
"layers": "Geruzak",
"actions": "Ekintzak",
"language": "Hizkuntza",
"liveCollaboration": "Zuzeneko elkarlana...",
"liveCollaboration": "Zuzeneko elkarlana",
"duplicateSelection": "Bikoiztu",
"untitled": "Izengabea",
"name": "Izena",
@@ -116,8 +115,8 @@
"label": "Esteka"
},
"lineEditor": {
"edit": "Editatu lerroa",
"exit": "Irten lerro-editoretik"
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "Blokeatu",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Garbitu oihala",
"exportJSON": "Esportatu fitxategira",
"exportImage": "Esportatu irudia...",
"export": "Gorde hemen...",
"exportImage": "Gorde irudi gisa",
"export": "Esportatu",
"exportToPng": "Esportatu PNG gisa",
"exportToSvg": "Esportatu SVG gisa",
"copyToClipboard": "Kopiatu arbelera",
@@ -145,7 +144,7 @@
"scale": "Eskala",
"save": "Gorde uneko fitxategian",
"saveAs": "Gorde honela",
"load": "Ireki",
"load": "Kargatu",
"getShareableLink": "Lortu partekatzeko esteka",
"close": "Itxi",
"selectLanguage": "Hautatu hizkuntza",
@@ -215,7 +214,7 @@
"text": "Testua",
"library": "Liburutegia",
"lock": "Mantendu aktibo hautatutako tresna marraztu ondoren",
"penMode": "Luma modua - ukipena saihestu",
"penMode": "Saihestu txikiagotzea eta onartu marrazte libreko idazketa solik arkatza bidez",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma"
},
@@ -236,7 +235,7 @@
"resize": "Proportzioak mantendu ditzakezu SHIFT sakatuta tamaina aldatzen duzun bitartean.\nsakatu ALT erditik tamaina aldatzeko",
"resizeImage": "Tamaina libreki alda dezakezu SHIFT sakatuta,\nsakatu ALT erditik tamaina aldatzeko",
"rotate": "Angeluak mantendu ditzakezu SHIFT sakatuta biratzen duzun bitartean",
"lineEditor_info": "",
"lineEditor_info": "Egin klik bikoitza edo sakatu Sartu puntuak editatzeko",
"lineEditor_pointSelected": "Sakatu Ezabatu puntuak kentzeko,\nKtrl+D bikoizteko, edo arrastatu mugitzeko",
"lineEditor_nothingSelected": "Hautatu editatzeko puntu bat (SHIFT sakatuta anitz hautatzeko),\nedo eduki Alt sakatuta eta egin klik puntu berriak gehitzeko",
"placeImage": "Egin klik irudia kokatzeko, edo egin klik eta arrastatu bere tamaina eskuz ezartzeko",
@@ -311,9 +310,7 @@
"view": "Bistaratu",
"zoomToFit": "Egin zoom elementu guztiak ikusteko",
"zoomToSelection": "Zooma hautapenera",
"toggleElementLock": "Blokeatu/desbloketatu hautapena",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Blokeatu/desbloketatu hautapena"
},
"clearCanvasDialog": {
"title": "Garbitu oihala"
@@ -394,8 +391,7 @@
"fileSaved": "Fitxategia gorde da.",
"fileSavedToFilename": "{filename}-n gorde da",
"canvas": "oihala",
"selection": "hautapena",
"pasteAsSingleElement": ""
"selection": "hautapena"
},
"colors": {
"ffffff": "Zuria",
@@ -443,12 +439,5 @@
"5c940d": "Lima 9",
"e67700": "Horia 9",
"d9480f": "Laranja 9"
},
"welcomeScreen": {
"data": "Zure datu guztiak modu lokalean gordetzen dira zure nabigatzailean.",
"switchToPlusApp": "Horren ordez Excalidraw+-ra joan nahi al zenuen?",
"menuHints": "Esportatu, hobespenak, hizkuntzak,...",
"toolbarHints": "Aukeratu tresna bat eta hasi marrazten!",
"helpHints": "Lasterbideak eta laguntza"
}
}
+14 -25
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "جای گذاری",
"pasteAsPlaintext": "",
"pasteCharts": "قراردادن نمودارها",
"selectAll": "انتخاب همه",
"multiSelect": "یک ایتم به انتخاب شده ها اضافه کنید.",
@@ -72,7 +71,7 @@
"layers": "لایه ها",
"actions": "عملیات",
"language": "زبان",
"liveCollaboration": "همکاری آنلاین...",
"liveCollaboration": "همکاری زنده",
"duplicateSelection": "تکرار",
"untitled": "بدون عنوان",
"name": "نام",
@@ -116,8 +115,8 @@
"label": "لینک"
},
"lineEditor": {
"edit": "ویرایش لینک",
"exit": "خروج از ویرایشگر"
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "قفل",
@@ -126,18 +125,18 @@
"unlockAll": "باز کردن قفل همه"
},
"statusPublished": "منتشر شده",
"sidebarLock": "باز نگه داشتن سایدبار"
"sidebarLock": ""
},
"library": {
"noItems": "آیتمی به اینجا اضافه نشده...",
"hint_emptyLibrary": "یک آیتم روی بوم را برای اضافه شده به اینجا انتخاب کنید، یا یک کتابخانه از مخزن عمومی در بخش پایین را نصب کنید.",
"hint_emptyPrivateLibrary": "یک آیتم روی بوم را برای اضافه شدن به اینجا انتخاب کنید."
"noItems": "",
"hint_emptyLibrary": "",
"hint_emptyPrivateLibrary": ""
},
"buttons": {
"clearReset": "پاکسازی بوم نقاشی",
"exportJSON": "خروجی در فایل",
"exportImage": "خروجی گرفتن از تصویر...",
"export": "ذخیره در...",
"exportImage": "ذخیره به عنوان عکس",
"export": "تبدیل",
"exportToPng": "تبدیل به PNG",
"exportToSvg": "تبدیل به SVG",
"copyToClipboard": "کپی در حافظه موقت",
@@ -145,7 +144,7 @@
"scale": "مقیاس",
"save": "ذخیره در همین فایل",
"saveAs": "ذخیره با نام",
"load": "باز کردن",
"load": "بارگذاری",
"getShareableLink": "دریافت لینک قابل اشتراک",
"close": "بستن",
"selectLanguage": "انتخاب زبان",
@@ -215,7 +214,7 @@
"text": "متن",
"library": "کتابخانه",
"lock": "ابزار انتخاب شده را بعد از کشیدن نگه دار",
"penMode": "حالت قلم - جلوگیری از تماس",
"penMode": "از زوم کوچک کردن جلوگیری کنید و ورودی آزاد را فقط از قلم بپذیرید",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن"
},
@@ -236,7 +235,7 @@
"resize": "می توانید با نگه داشتن SHIFT در هنگام تغییر اندازه، نسبت ها را محدود کنید،ALT را برای تغییر اندازه از مرکز نگه دارید",
"resizeImage": "با نگه داشتن SHIFT می توانید آزادانه اندازه را تغییر دهید،\nبرای تغییر اندازه از مرکز، ALT را نگه دارید",
"rotate": "با نگه داشتن SHIFT هنگام چرخش می توانید زاویه ها را محدود کنید",
"lineEditor_info": "",
"lineEditor_info": "دوبار کلیک کنید یا Enter را فشار دهید تا نقاط را ویرایش کنید",
"lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید",
"lineEditor_nothingSelected": "یک نقطه را برای ویرایش انتخاب کنید (SHIFT را برای انتخاب چندگانه نگه دارید)،\nیا Alt را نگه دارید و برای افزودن نقاط جدید کلیک کنید",
"placeImage": "برای قرار دادن تصویر کلیک کنید، یا کلیک کنید و بکشید تا اندازه آن به صورت دستی تنظیم شود",
@@ -311,9 +310,7 @@
"view": "مشاهده",
"zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها",
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده",
"toggleElementLock": "قفل/بازکردن انتخاب شده ها",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "قفل/بازکردن انتخاب شده ها"
},
"clearCanvasDialog": {
"title": "پاک کردن بوم"
@@ -394,8 +391,7 @@
"fileSaved": "فایل ذخیره شد.",
"fileSavedToFilename": "ذخیره در {filename}",
"canvas": "بوم",
"selection": "انتخاب",
"pasteAsSingleElement": ""
"selection": "انتخاب"
},
"colors": {
"ffffff": "سفید",
@@ -443,12 +439,5 @@
"5c940d": "لیمویی 9",
"e67700": "زرد 9",
"d9480f": "نارنجی 9"
},
"welcomeScreen": {
"data": "همه ی داده های شما به صورت محلی در مرورگر ذخیره میشود.",
"switchToPlusApp": "آیا ترجیح میدهید به Excalidraw+ بروید؟",
"menuHints": "خروجی گرفتن، تنظیمات، زبانها، ...",
"toolbarHints": "یک ابزار را انتخاب کنید و ترسیم را شروع کنید!",
"helpHints": "میانبرها و کمک"
}
}
+8 -19
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Liitä",
"pasteAsPlaintext": "",
"pasteCharts": "Liitä kaaviot",
"selectAll": "Valitse kaikki",
"multiSelect": "Lisää kohde valintaan",
@@ -72,7 +71,7 @@
"layers": "Tasot",
"actions": "Toiminnot",
"language": "Kieli",
"liveCollaboration": "",
"liveCollaboration": "Live-yhteistyö",
"duplicateSelection": "Monista",
"untitled": "Nimetön",
"name": "Nimi",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Tyhjennä piirtoalue",
"exportJSON": "Vie tiedostoon",
"exportImage": "",
"export": "",
"exportImage": "Tallenna kuvana",
"export": "Vie",
"exportToPng": "Vie PNG-tiedostona",
"exportToSvg": "Vie SVG-tiedostona",
"copyToClipboard": "Kopioi leikepöydälle",
@@ -145,7 +144,7 @@
"scale": "Koko",
"save": "Tallenna nykyiseen tiedostoon",
"saveAs": "Tallenna nimellä",
"load": "",
"load": "Avaa",
"getShareableLink": "Hae jaettava linkki",
"close": "Sulje",
"selectLanguage": "Valitse kieli",
@@ -215,7 +214,7 @@
"text": "Teksti",
"library": "Kirjasto",
"lock": "Pidä valittu työkalu aktiivisena piirron jälkeen",
"penMode": "",
"penMode": "Estä nipistyszoomaus ja vastaanota ainoastaan kynällä piirretty",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu"
},
@@ -236,7 +235,7 @@
"resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen",
"resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri",
"rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi",
"lineEditor_info": "",
"lineEditor_info": "Kaksoisnapauta tai paina Enter muokataksesi pisteitä",
"lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla",
"lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä",
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
@@ -311,9 +310,7 @@
"view": "Näkymä",
"zoomToFit": "Näytä kaikki elementit",
"zoomToSelection": "Näytä valinta",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Pyyhi piirtoalue"
@@ -394,8 +391,7 @@
"fileSaved": "Tiedosto tallennettu.",
"fileSavedToFilename": "Tallennettiin kohteeseen {filename}",
"canvas": "piirtoalue",
"selection": "valinta",
"pasteAsSingleElement": ""
"selection": "valinta"
},
"colors": {
"ffffff": "Valkoinen",
@@ -443,12 +439,5 @@
"5c940d": "Limenvihreä 9",
"e67700": "Keltainen 9",
"d9480f": "Oranssi 9"
},
"welcomeScreen": {
"data": "",
"switchToPlusApp": "",
"menuHints": "",
"toolbarHints": "",
"helpHints": ""
}
}
+8 -19
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Coller",
"pasteAsPlaintext": "",
"pasteCharts": "Coller les graphiques",
"selectAll": "Tout sélectionner",
"multiSelect": "Ajouter l'élément à la sélection",
@@ -45,7 +44,7 @@
"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\"",
"handDrawn": "Manuscrit",
"handDrawn": "À la main",
"normal": "Normale",
"code": "Code",
"small": "Petite",
@@ -72,7 +71,7 @@
"layers": "Disposition",
"actions": "Actions",
"language": "Langue",
"liveCollaboration": "Collaboration en direct...",
"liveCollaboration": "Collaboration en direct",
"duplicateSelection": "Dupliquer",
"untitled": "Sans-titre",
"name": "Nom",
@@ -136,8 +135,8 @@
"buttons": {
"clearReset": "Réinitialiser le canevas",
"exportJSON": "Exporter comme fichier",
"exportImage": "Exporter l'image...",
"export": "Enregistrer sous...",
"exportImage": "Enregistrer comme image",
"export": "Exporter",
"exportToPng": "Enregistrer en PNG",
"exportToSvg": "Enregistrer en SVG",
"copyToClipboard": "Copier dans le presse-papier",
@@ -215,7 +214,7 @@
"text": "Texte",
"library": "Bibliothèque",
"lock": "Garder l'outil sélectionné actif après le dessin",
"penMode": "Mode stylo - évite le toucher",
"penMode": "Empêcher le zoom tactile et accepter la saisie libre uniquement à partir du stylet",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme"
},
@@ -236,7 +235,7 @@
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement, maintenez 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": "",
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
"lineEditor_pointSelected": "Appuyer sur Suppr. pour supprimer des points, Ctrl ou Cmd+D pour dupliquer, ou faire glisser pour déplacer",
"lineEditor_nothingSelected": "Sélectionner un point pour éditer (maintenir la touche MAJ pour en sélectionner plusieurs),\nou maintenir la touche Alt enfoncée et cliquer pour ajouter de nouveaux points",
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
@@ -311,9 +310,7 @@
"view": "Affichage",
"zoomToFit": "Zoomer pour voir tous les éléments",
"zoomToSelection": "Zoomer sur la sélection",
"toggleElementLock": "Verrouiller/déverrouiller la sélection",
"movePageUpDown": "",
"movePageLeftRight": ""
"toggleElementLock": "Verrouiller/déverrouiller la sélection"
},
"clearCanvasDialog": {
"title": "Effacer la zone de dessin"
@@ -394,8 +391,7 @@
"fileSaved": "Fichier enregistré.",
"fileSavedToFilename": "Enregistré sous {filename}",
"canvas": "canevas",
"selection": "sélection",
"pasteAsSingleElement": ""
"selection": "sélection"
},
"colors": {
"ffffff": "Blanc",
@@ -443,12 +439,5 @@
"5c940d": "Citron vert 9",
"e67700": "Jaune 9",
"d9480f": "Orange 9"
},
"welcomeScreen": {
"data": "Toutes vos données sont sauvegardées en local dans votre navigateur.",
"switchToPlusApp": "Vous vouliez plutôt aller à Excalidraw+ ?",
"menuHints": "Exportation, préférences, langues, ...",
"toolbarHints": "Choisissez un outil et commencez à dessiner !",
"helpHints": "Raccourcis et aide"
}
}
+216 -227
View File
@@ -1,7 +1,6 @@
{
"labels": {
"paste": "Pegar",
"pasteAsPlaintext": "",
"paste": "Paste",
"pasteCharts": "Pegar gráficos",
"selectAll": "Seleccionar todo",
"multiSelect": "Engadir elemento á selección",
@@ -43,9 +42,9 @@
"onlySelected": "Só seleccionados",
"withBackground": "Fondo",
"exportEmbedScene": "Inserir escena",
"exportEmbedScene_details": "Os datos da escena serán gardados no ficheiro PNG/SVG exportado polo que a escena poderá ser restaurada dende el. Isto aumentará o tamaño do ficheiro exportado.",
"exportEmbedScene_details": "",
"addWatermark": "Engadir \"Feito con Excalidraw\"",
"handDrawn": "Debuxado a man",
"handDrawn": "Debuzado á man",
"normal": "Normal",
"code": "Código",
"small": "Pequeno",
@@ -72,7 +71,7 @@
"layers": "Capas",
"actions": "Accións",
"language": "Idioma",
"liveCollaboration": "Colaboración en directo...",
"liveCollaboration": "Colaboración en directo",
"duplicateSelection": "Duplicar",
"untitled": "Sen título",
"name": "Nome",
@@ -96,8 +95,8 @@
"centerHorizontally": "Centrar horizontalmente",
"distributeHorizontally": "Distribuír horizontalmente",
"distributeVertically": "Distribuír verticalmente",
"flipHorizontal": "Virar horizontalmente",
"flipVertical": "Virar verticalmente",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "Modo de visualización",
"toggleExportColorScheme": "Alternar esquema de cores de exportación",
"share": "Compartir",
@@ -116,8 +115,8 @@
"label": "Ligazón"
},
"lineEditor": {
"edit": "Editar liña",
"exit": "Saír do editor de liñas"
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "Bloquear",
@@ -130,14 +129,14 @@
},
"library": {
"noItems": "Aínda non hai elementos engadidos...",
"hint_emptyLibrary": "Seleccione un elemento no lenzo para engadilo aquí, ou instale unha biblioteca dende o repositorio público, como se detalla a continuación.",
"hint_emptyPrivateLibrary": "Seleccione un elemento do lenzo para engadilo aquí."
"hint_emptyLibrary": "",
"hint_emptyPrivateLibrary": ""
},
"buttons": {
"clearReset": "Limpar o lenzo",
"exportJSON": "Exportar a arquivo",
"exportImage": "Exportar imaxe...",
"export": "Gardar en...",
"exportImage": "Gardar como imaxe",
"export": "Exportar",
"exportToPng": "Exportar a PNG",
"exportToSvg": "Exportar a SVG",
"copyToClipboard": "Copiar ao portapapeis",
@@ -145,7 +144,7 @@
"scale": "Escala",
"save": "Gardar no ficheiro actual",
"saveAs": "Gardar como",
"load": "Abrir",
"load": "Cargar",
"getShareableLink": "Obter unha ligazón que se poida compartir",
"close": "Pechar",
"selectLanguage": "Seleccionar idioma",
@@ -177,35 +176,35 @@
"couldNotCreateShareableLink": "Non se puido crear unha ligazón para compartir.",
"couldNotCreateShareableLinkTooBig": "Non se puido crear a ligazón para compartir: a escena é demasiado grande",
"couldNotLoadInvalidFile": "Non se puido cargar o ficheiro non válido",
"importBackendFailed": "A importación dende o backend fallou.",
"cannotExportEmptyCanvas": "Non se pode exportar un lenzo baleiro.",
"couldNotCopyToClipboard": "Non se puido copiar ao portapapeis.",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "Non se poideron descifrar os datos.",
"uploadedSecurly": "A carga foi asegurada con cifrado de extremo a extremo, o que significa que o servidor de Excalidraw e terceiros non poden ler o contido.",
"loadSceneOverridePrompt": "A carga dun debuxo externo substituirá o contido existente. Desexa continuar?",
"collabStopOverridePrompt": "Deter a sesión, sobrescribirá o seu debuxo local previamente almacenado. Está seguro?\n\n(Se quere manter o seu debuxo local, simplemente peche a lapela do navegador.)",
"errorAddingToLibrary": "Non se puido engadir o elemento á biblioteca",
"errorRemovingFromLibrary": "Non se puido eliminar o elemento da biblioteca",
"confirmAddLibrary": "Isto engadirá {{numShapes}} forma(s) a túa biblioteca. Estás seguro?",
"imageDoesNotContainScene": "Esta imaxe non parece conter ningún dato da escena. Activou a inserción de escenas durante a exportación?",
"cannotRestoreFromImage": "Non se puido restaurar a escena dende este arquivo de imaxe",
"invalidSceneUrl": "Non se puido importar a escena dende a URL proporcionada. Ou ben está malformada ou non contén un JSON con información válida para Excalidraw.",
"resetLibrary": "Isto limpará a súa biblioteca. Está seguro?",
"removeItemsFromsLibrary": "Eliminar {{count}} elemento(s) da biblioteca?",
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada."
"uploadedSecurly": "A carga foi asegurada con cifrado de extremo a extremo, o que significa que o servidor de Excalidraw e terceiros non poder ler o contenido.",
"loadSceneOverridePrompt": "Si carga este debuxo externo, reemprazará o que ten. ¿Desexa continuar?",
"collabStopOverridePrompt": "",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
"unsupportedFileType": "Tipo de ficheiro non soportado.",
"imageInsertError": "Non se puido inserir a imaxe. Probe de novo máis tarde...",
"fileTooBig": "O ficheiro é demasiado grande. O tamaño máximo permitido é {{maxSize}}.",
"svgImageInsertError": "Non se puido inserir como imaxe SVG. O marcado SVG semella inválido.",
"invalidSVGString": "SVG inválido.",
"cannotResolveCollabServer": "Non se puido conectar ao servidor de colaboración. Por favor recargue a páxina e probe de novo.",
"importLibraryError": "Non se puido cargar a biblioteca"
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": ""
},
"toolBar": {
"selection": "Selección",
"image": "Inserir imaxe",
"image": "",
"rectangle": "Rectángulo",
"diamond": "Diamante",
"ellipse": "Elipse",
@@ -215,240 +214,230 @@
"text": "Texto",
"library": "Biblioteca",
"lock": "Manter a ferramenta seleccionada activa despois de debuxar",
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar"
"penMode": "",
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "Accións do lenzo",
"selectedShapeActions": "Accións da forma seleccionada",
"shapes": "Formas"
"selectedShapeActions": "",
"shapes": ""
},
"hints": {
"canvasPanning": "Para mover o lenzo, manteña a roda do rato ou a barra de espazo mentres arrastra",
"linearElement": "Faga clic para iniciar varios puntos, arrastre para unha sola liña",
"freeDraw": "Fai clic e arrastra, solta cando acabes",
"text": "Consello: tamén podes engadir texto facendo dobre-clic en calquera lugar coa ferramenta de selección",
"text_selected": "Dobre-clic ou prema ENTER para editar o texto",
"text_editing": "Prema Escape ou CtrlOrCmd+ENTER para finalizar a edición",
"linearElementMulti": "Faga clic no último punto ou prema Escape ou Enter para rematar",
"lockAngle": "Pode reducir o ángulo mantendo SHIFT",
"resize": "Pode reducir as proporcións mantendo SHIFT mentres axusta o tamaño,\nmanteña ALT para axustalo dende o centro",
"resizeImage": "Pode axustar o tamaño libremente mantendo SHIFT,\nmanteña ALT para axustalo dende o centro",
"rotate": "Podes reducir os ángulos mantendo SHIFT mentres os rotas",
"canvasPanning": "",
"linearElement": "",
"freeDraw": "",
"text": "",
"text_selected": "",
"text_editing": "",
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "Prema Suprimir para eliminar o(s) punto(s)\nCtrlOrCmd+D para duplicalos, ou arrastre para movelos",
"lineEditor_nothingSelected": "Seleccione un punto para editar (manteña pulsado SHIFT para selección múltiple),\nou manteña pulsado Alt e faga clic para engadir novos puntos",
"placeImage": "Faga clic para colocar a imaxe, ou faga clic e arrastre para establecer o seu tamaño manualmente",
"publishLibrary": "Publica a túa propia biblioteca",
"bindTextToElement": "Prema a tecla enter para engadir texto",
"deepBoxSelect": "Manteña pulsado CtrlOrCmd para seleccionar en profundidade e evitar o arrastre",
"eraserRevert": "Manteña pulsado Alt para reverter os elementos marcados para a súa eliminación"
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "Non se pode mostrar a vista previa",
"canvasTooBig": "Pode que o lenzo sexa demasiado grande.",
"canvasTooBigTip": "Consello: Probe a acercar un pouco os elementos máis afastados."
"cannotShowPreview": "",
"canvasTooBig": "",
"canvasTooBigTip": ""
},
"errorSplash": {
"headingMain_pre": "Atopouse un erro. Probe ",
"headingMain_button": "recargando a páxina.",
"clearCanvasMessage": "Se recargar non funcionou, probe ",
"clearCanvasMessage_button": "limpando o lenzo.",
"clearCanvasCaveat": " Isto resultará nunha perda do seu traballo ",
"trackedToSentry_pre": "O erro con identificador ",
"trackedToSentry_post": " foi rastrexado no noso sistema.",
"openIssueMessage_pre": "Fomos moi cautelosos de non incluír a información da súa escena no erro. Se a súa escena non é privada, por favor, considere o seguimento do noso ",
"openIssueMessage_button": "rastrexador de erros.",
"openIssueMessage_post": " Por favor inclúa a seguinte información copiándoa e pegándoa na issue de Github.",
"sceneContent": "Contido da escena:"
"headingMain_pre": "",
"headingMain_button": "",
"clearCanvasMessage": "",
"clearCanvasMessage_button": "",
"clearCanvasCaveat": "",
"trackedToSentry_pre": "",
"trackedToSentry_post": "",
"openIssueMessage_pre": "",
"openIssueMessage_button": "",
"openIssueMessage_post": "",
"sceneContent": ""
},
"roomDialog": {
"desc_intro": "Podes invitar xente a colaborar contigo na túa escena actual.",
"desc_privacy": "Non te preocupes, a sesión usa cifrado de punto a punto, polo que calquera cousa que debuxes mantense privada. Nin tan sequera o noso servidor será capaz de ver o que fas.",
"button_startSession": "Comezar sesión",
"button_stopSession": "Rematar sesión",
"desc_inProgressIntro": "A sesión de colaboración en directo está agora en progreso.",
"desc_shareLink": "Comparte esta ligazón con calquera que queiras colaborar:",
"desc_exitSession": "Deter a sesión desconectarao da sala, pero poderá seguir traballando coa escena de maneira local. Teña en conta que isto non afectará a outras persoas, que poderán seguir colaborando na súa versión.",
"shareTitle": "Únase a unha sesión de colaboración en directo en Excalidraw"
"desc_intro": "",
"desc_privacy": "",
"button_startSession": "",
"button_stopSession": "",
"desc_inProgressIntro": "",
"desc_shareLink": "",
"desc_exitSession": "",
"shareTitle": ""
},
"errorDialog": {
"title": "Erro"
},
"exportDialog": {
"disk_title": "Gardar no disco",
"disk_details": "Exporte os datos da escena a un ficheiro que poderás importar máis tarde.",
"disk_button": "Gardar nun ficheiro",
"link_title": "Ligazón para compartir",
"link_details": "Exportar como unha ligazón de só lectura.",
"link_button": "Exportar a unha ligazón",
"excalidrawplus_description": "Garde a escena no seu espazo de traballo en Excalidraw+.",
"excalidrawplus_button": "Exportar",
"excalidrawplus_exportError": "Non se puido exportar a Excalidraw+ neste momento..."
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": "",
"excalidrawplus_description": "",
"excalidrawplus_button": "",
"excalidrawplus_exportError": ""
},
"helpDialog": {
"blog": "Le o noso blog",
"click": "clic",
"deepSelect": "Selección en profundidade",
"deepBoxSelect": "Selección en profundidade dentro da caixa, evitando o arrastre",
"curvedArrow": "Frecha curva",
"curvedLine": "Liña curva",
"documentation": "Documentación",
"doubleClick": "dobre-clic",
"drag": "arrastrar",
"editor": "Editor",
"editSelectedShape": "Editar a forma seleccionada (texto/frecha/liña)",
"github": "Encontrou un problema? Envíeo",
"howto": "Sigue as nosas normas",
"or": "ou",
"preventBinding": "Evitar a unión de frechas",
"tools": "Ferramentas",
"shortcuts": "Atallos de teclado",
"textFinish": "Rematar de editar (editor de texto)",
"textNewLine": "Engadir unha nova liña (editor de texto)",
"title": "Axuda",
"view": "Vista",
"zoomToFit": "Zoom que se axuste a todos os elementos",
"zoomToSelection": "Zoom á selección",
"toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "",
"movePageLeftRight": ""
"blog": "",
"click": "",
"deepSelect": "",
"deepBoxSelect": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"doubleClick": "",
"drag": "",
"editor": "",
"editSelectedShape": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"tools": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": "",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Limpar lenzo"
"title": ""
},
"publishDialog": {
"title": "Publicar biblioteca",
"itemName": "Nome do elemento",
"authorName": "Nome do autor",
"githubUsername": "Nome de usuario en Github",
"twitterUsername": "Nome de usuario en Twitter",
"libraryName": "Nome da biblioteca",
"libraryDesc": "Descrición da biblioteca",
"website": "Páxina web",
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "O seu nome ou nome de usuario",
"libraryName": "Nome da súa biblioteca",
"libraryDesc": "Descrición da súa biblioteca para axudar a xente a entender o seu uso",
"githubHandle": "Nome de usuario de GitHub (opcional), así poderás editar a biblioteca unha vez enviada para a súa revisión",
"twitterHandle": "Nome de usuario en Twitter(opcional), así sabemos a quen darlle crédito cando se lle de promoción a través de Twitter",
"website": "Ligazón ao teu sitio web persoal ou a outro sitio (opcional)"
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "Obrigatorio",
"website": "Introduza unha URL válida"
"required": "",
"website": ""
},
"noteDescription": {
"pre": "Envíe a súa biblioteca para que sexa incluída no ",
"link": "repositorio público de bibliotecas",
"post": "para que outra xente a poida usar nos seus debuxos."
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "A biblioteca necesita ser aprobada manualmente primeiro. Por favor, lea as ",
"link": "normas",
"post": " antes de ser enviado. Necesitarás unha conta de GitHub para comunicarte ou facer cambios se se solicitan, pero non é estritamente necesario."
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "Ao enviar, estás de acordo con que a biblioteca sexa publicada baixo a ",
"link": "Licenza MIT, ",
"post": "o cal significa que, en resumo, calquera pode usalo sen restricións."
"pre": "",
"link": "",
"post": ""
},
"noteItems": "Cada elemento da biblioteca debe ter o seu nome propio para que se poida filtrar. Os seguintes elementos da biblioteca serán incluídos:",
"atleastOneLibItem": "Por favor seleccione polo menos un elemento da biblioteca para comezar",
"republishWarning": "Nota: algúns dos elementos seleccionados están marcados como xa publicados/enviados. Só deberías reenviar elementos cando se actualice unha biblioteca ou envío."
"noteItems": "",
"atleastOneLibItem": "",
"republishWarning": ""
},
"publishSuccessDialog": {
"title": "Biblioteca enviada",
"content": "Grazas {{authorName}}. A súa biblioteca foi enviada para ser revisada. Pode seguir o estado",
"link": "aquí"
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "Restablecer biblioteca",
"removeItemsFromLib": "Eliminar os elementos seleccionados da biblioteca"
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Os teus debuxos están cifrados de punto a punto, polo que os servidores de Excalidraw nunca os verán.",
"link": "Entrada do blog acerca do cifrado de punto a punto en Excalidraw"
"tooltip": "",
"link": ""
},
"stats": {
"angle": "Ángulo",
"element": "Elemento",
"elements": "Elementos",
"height": "Alto",
"scene": "Escena",
"selected": "Seleccionado",
"storage": "Almacenamento",
"title": "Estadísticas para nerds",
"total": "Total",
"version": "Versión",
"versionCopy": "Faga clic para copiar",
"versionNotAvailable": "Versión non dispoñible",
"width": "Ancho"
"angle": "",
"element": "",
"elements": "",
"height": "",
"scene": "",
"selected": "",
"storage": "",
"title": "",
"total": "",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": ""
},
"toast": {
"addedToLibrary": "Engadido á biblioteca",
"copyStyles": "Estilos copiados.",
"copyToClipboard": "Copiado ao portapapeis.",
"copyToClipboardAsPng": "Copiar {{exportSelection}} ao portapapeis como PNG\n({{exportColorScheme}})",
"fileSaved": "Ficheiro gardado.",
"fileSavedToFilename": "Gardado en {filename}",
"canvas": "lenzo",
"selection": "selección",
"pasteAsSingleElement": ""
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": ""
},
"colors": {
"ffffff": "Branco",
"f8f9fa": "Gris 0",
"f1f3f5": "Gris 1",
"fff5f5": "Vermello 0",
"fff0f6": "Rosa 0",
"f8f0fc": "Uva 0",
"f3f0ff": "Violeta 0",
"edf2ff": "Índigo 0",
"e7f5ff": "Azul 0",
"e3fafc": "Ciano 0",
"e6fcf5": "Turquesa 0",
"ebfbee": "Verde 0",
"f4fce3": "Lima 0",
"fff9db": "Amarelo 0",
"fff4e6": "Laranxa 0",
"transparent": "Transparente",
"ced4da": "Gris 4",
"868e96": "Gris 6",
"fa5252": "Vermello 6",
"e64980": "Rosa 6",
"be4bdb": "Uva 6",
"7950f2": "Violeta 6",
"4c6ef5": "Índigo 6",
"228be6": "Azul 6",
"15aabf": "Ciano 6",
"12b886": "Turquesa 6",
"40c057": "Verde 6",
"82c91e": "Lima 6",
"fab005": "Amarelo 6",
"fd7e14": "Laranxa 6",
"000000": "Negro",
"343a40": "Gris 8",
"495057": "Gris 7",
"c92a2a": "Vermello 9",
"a61e4d": "Rosa 9",
"862e9c": "Uva 9",
"5f3dc4": "Violeta 9",
"364fc7": "Índigo 9",
"1864ab": "Azul 9",
"0b7285": "Ciano 9",
"087f5b": "Turquesa 9",
"2b8a3e": "Verde 9",
"5c940d": "Lima 9",
"e67700": "Amarelo 9",
"d9480f": "Laranxa 9"
},
"welcomeScreen": {
"data": "Toda a información é gardada de maneira local no seu navegador.",
"switchToPlusApp": "Queres ir a Excalidraw+ no seu lugar?",
"menuHints": "Exportar, preferencias, idiomas, ...",
"toolbarHints": "Escolle unha ferramenta & Comeza a debuxar!",
"helpHints": "Atallos & axuda"
"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": ""
}
}

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