Compare commits

..

3 Commits

Author SHA1 Message Date
dwelle 75e2d9e359 remove debug 2021-11-22 23:20:40 +01:00
dwelle 6592517122 import lazily 2021-11-22 22:11:10 +01:00
dwelle bd953a6287 feat: compress non-transparent PNGs as JPGs and allow larger dimensions 2021-11-22 17:39:59 +01:00
157 changed files with 2236 additions and 7276 deletions
View File
-1
View File
@@ -1,2 +1 @@
#!/bin/sh
yarn lint-staged
+10 -11
View File
@@ -21,15 +21,15 @@
"dependencies": {
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.15.1",
"@testing-library/jest-dom": "5.15.0",
"@testing-library/react": "12.1.2",
"@tldraw/vec": "1.1.5",
"@types/jest": "27.0.3",
"@tldraw/vec": "0.1.3",
"@types/jest": "27.0.2",
"@types/pica": "5.1.3",
"@types/react": "17.0.37",
"@types/react": "17.0.34",
"@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.23.0",
"browser-fs-access": "0.21.1",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
@@ -49,8 +49,8 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.43.5",
"roughjs": "4.5.0",
"sass": "1.43.4",
"socket.io-client": "2.3.1",
"typescript": "4.5.2"
},
@@ -62,15 +62,14 @@
"@types/pako": "1.0.2",
"@types/resize-observer-browser": "0.1.6",
"chai": "4.3.4",
"dotenv": "10.0.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.23.0",
"firebase-tools": "9.22.0",
"husky": "7.0.4",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.1.2",
"lint-staged": "12.0.1",
"pepjs": "0.5.3",
"prettier": "2.5.0",
"prettier": "2.4.1",
"rewire": "5.0.0"
},
"resolutions": {
+5 -7
View File
@@ -8,12 +8,7 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.some((element) => element.type === "image")) {
if (elements.some((element) => element.type === "image")) {
return {
commitToHistory: false,
appState: {
@@ -30,7 +25,10 @@ export const actionAddToLibrary = register({
{
id: randomId(),
status: "unpublished",
elements: selectedElements.map(deepCopyElement),
elements: getSelectedElements(
getNonDeletedElements(elements),
appState,
).map(deepCopyElement),
created: Date.now(),
},
...items,
+4 -6
View File
@@ -8,13 +8,13 @@ import {
CenterVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
@@ -34,11 +34,9 @@ const alignSelectedElements = (
const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const actionAlignTop = register({
+1 -1
View File
@@ -160,7 +160,7 @@ export const actionResetZoom = register({
};
},
PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
<Tooltip label={t("buttons.resetZoom")}>
<ToolButton
type="button"
className="reset-zoom-button"
-2
View File
@@ -42,7 +42,6 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
@@ -82,7 +81,6 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
try {
await exportCanvas(
+12 -22
View File
@@ -11,7 +11,6 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -22,12 +21,6 @@ const deleteSelectedElements = (
if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true });
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
appState: {
@@ -62,7 +55,7 @@ export const actionDeleteSelected = register({
if (appState.editingLinearElement) {
const {
elementId,
selectedPointsIndices,
activePointIndex,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
@@ -72,7 +65,8 @@ export const actionDeleteSelected = register({
}
if (
// case: no point selected → delete whole element
selectedPointsIndices == null ||
activePointIndex == null ||
activePointIndex === -1 ||
// case: deleting last remaining point
element.points.length < 2
) {
@@ -92,17 +86,15 @@ export const actionDeleteSelected = register({
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement: selectedPointsIndices?.includes(0)
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
element.points.length - 1,
)
? null
: endBindingElement,
startBindingElement:
activePointIndex === 0 ? null : startBindingElement,
endBindingElement:
activePointIndex === element.points.length - 1
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(element, selectedPointsIndices);
LinearElementEditor.movePoint(element, activePointIndex, "delete");
return {
elements,
@@ -111,15 +103,13 @@ export const actionDeleteSelected = register({
editingLinearElement: {
...appState.editingLinearElement,
...binding,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
: [0],
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
},
},
commitToHistory: true,
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
+4 -6
View File
@@ -4,13 +4,13 @@ import {
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../disitrubte";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
const enableActionGroup = (
@@ -30,11 +30,9 @@ const distributeSelectedElements = (
const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const distributeHorizontally = register({
+32 -22
View File
@@ -2,12 +2,13 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
@@ -17,23 +18,41 @@ import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
perform: (elements, appState) => {
// duplicate selected point(s) if editing a line
// duplicate point if selected while editing multi-point element
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
if (!ret) {
const { activePointIndex, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element || activePointIndex === null) {
return false;
}
const { points } = element;
const selectedPoint = points[activePointIndex];
const nextPoint = points[activePointIndex + 1];
mutateElement(element, {
points: [
...points.slice(0, activePointIndex + 1),
nextPoint
? [
(selectedPoint[0] + nextPoint[0]) / 2,
(selectedPoint[1] + nextPoint[1]) / 2,
]
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
...points.slice(activePointIndex + 1),
],
});
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: activePointIndex + 1,
},
},
elements,
appState: ret.appState,
commitToHistory: true,
};
}
@@ -87,12 +106,9 @@ const duplicateElements = (
const finalElements: ExcalidrawElement[] = [];
let index = 0;
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, true),
);
while (index < elements.length) {
const element = elements[index];
if (selectedElementIds.get(element.id)) {
if (appState.selectedElementIds[element.id]) {
if (element.groupIds.length) {
const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically
@@ -114,11 +130,7 @@ const duplicateElements = (
}
index++;
}
bindTextToShapeAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
@@ -128,9 +140,7 @@ const duplicateElements = (
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
acc[element.id] = true;
return acc;
}, {} as any),
},
+1 -52
View File
@@ -14,60 +14,10 @@ import {
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement } from "../element/typeChecks";
import { ExcalidrawImageElement } from "../element/types";
import { imageFromImageData } from "../element/image";
export const actionFinalize = register({
name: "finalize",
perform: (
elements,
appState,
_,
{ canvas, focusContainer, imageCache, addFiles },
) => {
if (appState.editingImageElement) {
const { elementId, imageData } = appState.editingImageElement;
const editingImageElement = elements.find((el) => el.id === elementId) as
| ExcalidrawImageElement
| undefined;
if (editingImageElement?.fileId) {
const cachedImageData = imageCache.get(editingImageElement.fileId);
if (cachedImageData) {
const { image, dataURL } = imageFromImageData(imageData);
imageCache.set(editingImageElement.fileId, {
...cachedImageData,
image,
});
addFiles([
{
id: editingImageElement.fileId,
dataURL,
mimeType: cachedImageData.mimeType,
created: Date.now(),
},
]);
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
}
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@@ -212,7 +162,6 @@ export const actionFinalize = register({
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
appState.editingImageElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
+7 -9
View File
@@ -1,6 +1,6 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { getElementMap, getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
@@ -9,7 +9,6 @@ import { getTransformHandles } from "../element/transformHandles";
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
import { updateBoundElements } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[],
@@ -84,11 +83,9 @@ const flipSelectedElements = (
flipDirection,
);
const updatedElementsMap = arrayToMap(updatedElements);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map(
(element) => updatedElementsMap.get(element.id) || element,
);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
const flipElements = (
@@ -145,9 +142,10 @@ const flipElement = (
}
if (isLinearElement(element)) {
for (let index = 1; index < element.points.length; index++) {
LinearElementEditor.movePoints(element, [
{ index, point: [-element.points[index][0], element.points[index][1]] },
for (let i = 1; i < element.points.length; i++) {
LinearElementEditor.movePoint(element, i, [
-element.points[i][0],
element.points[i][1],
]);
}
LinearElementEditor.normalizePoints(element);
+2 -5
View File
@@ -1,6 +1,6 @@
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
@@ -44,7 +44,6 @@ const enableActionGroup = (
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -57,7 +56,6 @@ export const actionGroup = register({
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
if (selectedElements.length < 2) {
// nothing to group
@@ -85,9 +83,8 @@ export const actionGroup = register({
}
}
const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
if (!selectElementIds.get(element.id)) {
if (!appState.selectedElementIds[element.id]) {
return element;
}
return newElementWith(element, {
+5 -5
View File
@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
const writeData = (
prevElements: readonly ExcalidrawElement[],
@@ -27,17 +27,17 @@ const writeData = (
return { commitToHistory };
}
const prevElementMap = arrayToMap(prevElements);
const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
-75
View File
@@ -1,75 +0,0 @@
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { backgroundIcon } from "../components/icons";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import { isInitializedImageElement } from "../element/typeChecks";
import Scene from "../scene/Scene";
export const actionEditImageAlpha = register({
name: "editImageAlpha",
perform: async (elements, appState, _, app) => {
if (appState.editingImageElement) {
return {
appState: {
...appState,
editingImageElement: null,
},
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(elements, appState);
const selectedElement = selectedElements[0];
if (
selectedElements.length === 1 &&
isInitializedImageElement(selectedElement)
) {
const imgData = app.imageCache.get(selectedElement.fileId);
if (!imgData) {
return false;
}
const image = await imgData.image;
const { width, height } = image;
const canvas = document.createElement("canvas");
canvas.height = height;
canvas.width = width;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
Scene.mapElementToScene(selectedElement.id, app.scene);
return {
appState: {
...appState,
editingImageElement: {
editorType: "alpha",
elementId: selectedElement.id,
origImageData: imageData,
imageData,
pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null },
},
},
commitToHistory: false,
};
}
return false;
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
icon={backgroundIcon}
label="Edit Image Alpha"
className={appState.editingImageElement ? "active" : ""}
title={"Edit image alpha"}
aria-label={"Edit image alpha"}
onClick={() => updateData(null)}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
+2 -5
View File
@@ -1,7 +1,7 @@
import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element";
import { getNonDeletedElements } from "../element";
export const actionSelectAll = register({
name: "selectAll",
@@ -15,10 +15,7 @@ export const actionSelectAll = register({
...appState,
editingGroupId: null,
selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId)
) {
if (!element.isDeleted) {
map[element.id] = true;
}
return map;
-1
View File
@@ -80,4 +80,3 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionEditImageAlpha } from "./actionImageEditing";
+1 -2
View File
@@ -101,8 +101,7 @@ export type ActionName =
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "editImageAlpha";
| "toggleTheme";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
-2
View File
@@ -41,7 +41,6 @@ export const getDefaultAppState = (): Omit<
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
editingImageElement: null,
elementLocked: false,
elementType: "selection",
errorMessage: null,
@@ -126,7 +125,6 @@ const APP_STATE_STORAGE_CONF = (<
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
editingImageElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
+1 -2
View File
@@ -58,8 +58,7 @@ export const copyToClipboard = async (
appState: AppState,
files: BinaryFiles,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElements = getSelectedElements(elements, appState);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements,
-8
View File
@@ -19,7 +19,6 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { isImageElement } from "../element/typeChecks";
export const SelectedShapeActions = ({
appState,
@@ -106,13 +105,6 @@ export const SelectedShapeActions = ({
<>{renderAction("changeArrowhead")}</>
)}
<fieldset>
<div className="buttonList">
{targetElements.some((element) => isImageElement(element)) &&
renderAction("editImageAlpha")}
</div>
</fieldset>
{renderAction("changeOpacity")}
<fieldset>
+125 -349
View File
@@ -43,7 +43,8 @@ import {
import {
APP_NAME,
CURSOR_TYPE,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
DEFAULT_UI_OPTIONS,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -120,7 +121,6 @@ import {
} from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import {
hasBoundTextElement,
isBindingElement,
isBindingElementType,
isImageElement,
@@ -175,7 +175,7 @@ import {
isSomeElementSelected,
} from "../scene";
import Scene from "../scene/Scene";
import { RenderConfig, ScrollBars } from "../scene/types";
import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import {
@@ -195,7 +195,6 @@ import {
import {
debounce,
distance,
getFontString,
getNearestScrollableContainer,
isInputLike,
isToolIcon,
@@ -224,20 +223,13 @@ import {
} from "../data/blob";
import {
getInitializedImageElements,
hasTransparentPixels,
loadHTMLImageElement,
normalizeSVG,
updateImageCache as _updateImageCache,
} from "../element/image";
import throttle from "lodash.throttle";
import { fileOpen, nativeFileSystemSupported } from "../data/filesystem";
import {
bindTextToShapeAfterDuplication,
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElementId,
} from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import { ImageEditor } from "../element/imageEditor";
const IsMobileContext = React.createContext(false);
export const useIsMobile = () => useContext(IsMobileContext);
@@ -282,7 +274,7 @@ class App extends React.Component<AppProps, AppState> {
UIOptions: DEFAULT_UI_OPTIONS,
};
public scene: Scene;
private scene: Scene;
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@@ -1032,14 +1024,8 @@ class App extends React.Component<AppProps, AppState> {
);
if (
(this.state.editingLinearElement &&
!this.state.selectedElementIds[
this.state.editingLinearElement.elementId
]) ||
(this.state.editingImageElement &&
!this.state.selectedElementIds[
this.state.editingImageElement.elementId
])
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
setTimeout(() => {
@@ -1069,10 +1055,8 @@ class App extends React.Component<AppProps, AppState> {
const cursorButton: {
[id: string]: string | undefined;
} = {};
const pointerViewportCoords: RenderConfig["remotePointerViewportCoords"] =
{};
const remoteSelectedElementIds: RenderConfig["remoteSelectedElementIds"] =
{};
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => {
@@ -1140,9 +1124,10 @@ class App extends React.Component<AppProps, AppState> {
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
theme: this.state.theme,
imageCache: this.imageCache,
isExporting: false,
},
{
renderOptimizations: true,
renderScrollbars: !this.isMobile,
editingImageElement: this.state.editingImageElement,
},
);
if (scrollBars) {
@@ -1150,7 +1135,7 @@ class App extends React.Component<AppProps, AppState> {
}
const scrolledOutside =
// hide when editing text
isTextElement(this.state.editingElement)
this.state.editingElement?.type === "text"
? false
: !atLeastOneVisibleElement && renderingElements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
@@ -1392,7 +1377,6 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId.set(element.id, newElement.id);
return newElement;
});
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [
...this.scene.getElementsIncludingDeleted(),
...newElements,
@@ -1411,9 +1395,7 @@ class App extends React.Component<AppProps, AppState> {
...this.state,
isLibraryOpen: false,
selectedElementIds: newElements.reduce((map, element) => {
if (isTextElement(element) && !element.containerId) {
map[element.id] = true;
}
map[element.id] = true;
return map;
}, {} as any),
selectedGroupIds: {},
@@ -1729,11 +1711,9 @@ class App extends React.Component<AppProps, AppState> {
!isLinearElement(selectedElements[0])
) {
const selectedElement = selectedElements[0];
this.startTextEditing({
sceneX: selectedElement.x + selectedElement.width / 2,
sceneY: selectedElement.y + selectedElement.height / 2,
shouldBind: true,
});
event.preventDefault();
return;
@@ -1888,24 +1868,14 @@ class App extends React.Component<AppProps, AppState> {
isExistingElement?: boolean;
},
) {
const updateElement = (
text: string,
originalText: string,
isDeleted = false,
updateDimensions = false,
) => {
const updateElement = (text: string, isDeleted = false) => {
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id && isTextElement(_element)) {
return updateTextElement(
_element,
{
text,
isDeleted,
originalText,
},
updateDimensions,
);
return updateTextElement(_element, {
text,
isDeleted,
});
}
return _element;
}),
@@ -1930,24 +1900,21 @@ class App extends React.Component<AppProps, AppState> {
];
},
onChange: withBatchedUpdates((text) => {
updateElement(text, text, false, !element.containerId);
updateElement(text);
if (isNonDeletedElement(element)) {
updateBoundElements(element);
}
}),
onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
onSubmit: withBatchedUpdates(({ text, viaKeyboard }) => {
const isDeleted = !text.trim();
updateElement(text, originalText, isDeleted, true);
updateElement(text, isDeleted);
// select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) {
const elementIdToSelect = element.containerId
? element.containerId
: element.id;
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
[elementIdToSelect]: true,
[element.id]: true,
},
}));
}
@@ -1976,7 +1943,7 @@ class App extends React.Component<AppProps, AppState> {
// do an initial update to re-initialize element position since we were
// modifying element's x/y for sake of editor (case: syncing to remote)
updateElement(element.text, element.originalText);
updateElement(element.text);
}
private deselectElements() {
@@ -1991,9 +1958,7 @@ class App extends React.Component<AppProps, AppState> {
x: number,
y: number,
): NonDeleted<ExcalidrawTextElement> | null {
const element = this.getElementAtPosition(x, y, {
includeBoundTextElement: true,
});
const element = this.getElementAtPosition(x, y);
if (element && isTextElement(element) && !element.isDeleted) {
return element;
@@ -2008,14 +1973,9 @@ class App extends React.Component<AppProps, AppState> {
/** if true, returns the first selected element (with highest z-index)
of all hit elements */
preferSelected?: boolean;
includeBoundTextElement?: boolean;
},
): NonDeleted<ExcalidrawElement> | null {
const allHitElements = this.getElementsAtPosition(
x,
y,
opts?.includeBoundTextElement,
);
const allHitElements = this.getElementsAtPosition(x, y);
if (allHitElements.length > 1) {
if (opts?.preferSelected) {
for (let index = allHitElements.length - 1; index > -1; index--) {
@@ -2046,16 +2006,8 @@ class App extends React.Component<AppProps, AppState> {
private getElementsAtPosition(
x: number,
y: number,
includeBoundTextElement: boolean = false,
): NonDeleted<ExcalidrawElement>[] {
const elements = includeBoundTextElement
? this.scene.getElements()
: this.scene
.getElements()
.filter(
(element) => !(isTextElement(element) && element.containerId),
);
return getElementsAtPosition(elements, (element) =>
return getElementsAtPosition(this.scene.getElements(), (element) =>
hitTest(element, this.state, x, y),
);
}
@@ -2063,17 +2015,17 @@ class App extends React.Component<AppProps, AppState> {
private startTextEditing = ({
sceneX,
sceneY,
shouldBind,
insertAtParentCenter = true,
}: {
/** X position to insert text at */
sceneX: number;
/** Y position to insert text at */
sceneY: number;
shouldBind: boolean;
/** whether to attempt to insert at element center if applicable */
insertAtParentCenter?: boolean;
}) => {
const existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
const parentCenterPosition =
insertAtParentCenter &&
this.getTextWysiwygSnappedToCenterPosition(
@@ -2084,43 +2036,6 @@ class App extends React.Component<AppProps, AppState> {
window.devicePixelRatio,
);
// bind to container when shouldBind is true or
// clicked on center of container
const container =
shouldBind || parentCenterPosition
? getElementContainingPosition(
this.scene.getElements(),
sceneX,
sceneY,
"text",
)
: null;
let existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
// consider bounded text element if container present
if (container) {
const boundTextElementId = getBoundTextElementId(container);
if (boundTextElementId) {
existingTextElement = this.scene.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
}
}
if (!existingTextElement && container) {
const fontString = {
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
};
const minWidth = getApproxMinLineWidth(getFontString(fontString));
const minHeight = getApproxMinLineHeight(getFontString(fontString));
const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth);
mutateElement(container, { height: newHeight, width: newWidth });
sceneX = container.x + newWidth / 2;
sceneY = container.y + newHeight / 2;
}
const element = existingTextElement
? existingTextElement
: newTextElement({
@@ -2147,7 +2062,6 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition
? "middle"
: DEFAULT_VERTICAL_ALIGN,
containerId: container?.id ?? undefined,
});
this.setState({ editingElement: element });
@@ -2218,7 +2132,7 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.canvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
@@ -2250,22 +2164,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.canvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
const selectedElements = getSelectedElements(
this.scene.getElements(),
this.state,
);
if (selectedElements.length === 1) {
const selectedElement = selectedElements[0];
const canBindText = hasBoundTextElement(selectedElement);
if (canBindText) {
sceneX = selectedElement.x + selectedElement.width / 2;
sceneY = selectedElement.y + selectedElement.height / 2;
}
}
this.startTextEditing({
sceneX,
sceneY,
shouldBind: false,
insertAtParentCenter: !event.altKey,
});
}
@@ -2338,10 +2239,6 @@ class App extends React.Component<AppProps, AppState> {
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer;
if (this.state.editingImageElement) {
return;
}
if (
this.state.editingLinearElement &&
!this.state.editingLinearElement.isDragging
@@ -2368,9 +2265,10 @@ class App extends React.Component<AppProps, AppState> {
// and point
const { draggingElement } = this.state;
if (isBindingElement(draggingElement)) {
this.maybeSuggestBindingsForLinearElementAtCoords(
this.maybeSuggestBindingForLinearElementAtCursor(
draggingElement,
[scenePointer],
"end",
scenePointer,
this.state.startBoundElement,
);
} else {
@@ -2503,21 +2401,6 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.canvas, CURSOR_TYPE.GRAB);
} else if (isOverScrollBar) {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.editingLinearElement) {
const element = LinearElementEditor.getElement(
this.state.editingLinearElement.elementId,
);
if (
element &&
isHittingElementNotConsideringBoundingBox(element, this.state, [
scenePointer.x,
scenePointer.y,
])
) {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
}
} else if (
// if using cmd/ctrl, we're not dragging
!event[KEYS.CTRL_OR_CMD] &&
@@ -2855,7 +2738,6 @@ class App extends React.Component<AppProps, AppState> {
origin,
selectedElements,
),
hasHitElementInside: false,
},
drag: {
hasOccurred: false,
@@ -2867,9 +2749,6 @@ class App extends React.Component<AppProps, AppState> {
onKeyUp: null,
onKeyDown: null,
},
boxSelection: {
hasOccurred: false,
},
};
}
@@ -2932,14 +2811,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
): boolean => {
if (this.state.elementType === "selection") {
if (this.state.editingImageElement) {
ImageEditor.handlePointerDown(
this.state.editingImageElement,
pointerDownState.origin,
);
return false;
}
const elements = this.scene.getElements();
const selectedElements = getSelectedElements(elements, this.state);
if (selectedElements.length === 1 && !this.state.editingLinearElement) {
@@ -3019,15 +2890,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
);
if (pointerDownState.hit.element) {
pointerDownState.hit.hasHitElementInside =
isHittingElementNotConsideringBoundingBox(
pointerDownState.hit.element,
this.state,
[pointerDownState.origin.x, pointerDownState.origin.y],
);
}
// For overlapped elements one position may hit
// multiple elements
pointerDownState.hit.allHitElements = this.getElementsAtPosition(
@@ -3048,14 +2910,8 @@ class App extends React.Component<AppProps, AppState> {
this.clearSelection(hitElement);
}
if (this.state.editingLinearElement) {
this.setState({
selectedElementIds: {
[this.state.editingLinearElement.elementId]: true,
},
});
// If we click on something
} else if (hitElement != null) {
// If we click on something
if (hitElement != null) {
// on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) {
if (!this.state.selectedElementIds[hitElement.id]) {
@@ -3148,25 +3004,13 @@ class App extends React.Component<AppProps, AppState> {
// if we're currently still editing text, clicking outside
// should only finalize it, not create another (irrespective
// of state.elementLocked)
if (isTextElement(this.state.editingElement)) {
if (this.state.editingElement?.type === "text") {
return;
}
let sceneX = pointerDownState.origin.x;
let sceneY = pointerDownState.origin.y;
const element = this.getElementAtPosition(sceneX, sceneY, {
includeBoundTextElement: true,
});
const canBindText = hasBoundTextElement(element);
if (canBindText) {
sceneX = element.x + element.width / 2;
sceneY = element.y + element.height / 2;
}
this.startTextEditing({
sceneX,
sceneY,
shouldBind: false,
sceneX: pointerDownState.origin.x,
sceneY: pointerDownState.origin.y,
insertAtParentCenter: !event.altKey,
});
@@ -3500,32 +3344,17 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (this.state.editingImageElement) {
const newImageData = ImageEditor.handlePointerMove(
this.state.editingImageElement,
pointerCoords,
);
if (newImageData) {
this.setState({
editingImageElement: {
...this.state.editingImageElement,
imageData: newImageData,
},
});
}
return;
}
if (this.state.editingLinearElement) {
const didDrag = LinearElementEditor.handlePointDragging(
this.state,
(appState) => this.setState(appState),
pointerCoords.x,
pointerCoords.y,
(element, pointsSceneCoords) => {
this.maybeSuggestBindingsForLinearElementAtCoords(
(element, startOrEnd) => {
this.maybeSuggestBindingForLinearElementAtCursor(
element,
pointsSceneCoords,
startOrEnd,
pointerCoords,
);
},
);
@@ -3542,16 +3371,8 @@ class App extends React.Component<AppProps, AppState> {
);
if (
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
// this allows for box-selecting points when clicking inside the
// line's bounding box
(!this.state.editingLinearElement || !event.shiftKey) &&
// box-selecting without shift when editing line, not clicking on a line
(!this.state.editingLinearElement ||
this.state.editingLinearElement?.elementId !==
pointerDownState.hit.element?.id ||
pointerDownState.hit.hasHitElementInside)
hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) {
// Marking that click was used for dragging to check
// if elements should be deselected on pointerup
@@ -3582,6 +3403,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements,
dragX,
dragY,
this.scene,
lockDirection,
dragDistanceX,
dragDistanceY,
@@ -3601,15 +3423,9 @@ class App extends React.Component<AppProps, AppState> {
const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElementsIncludingDeleted();
const selectedElementIds: Array<ExcalidrawElement["id"]> =
getSelectedElements(elements, this.state, true).map(
(element) => element.id,
);
for (const element of elements) {
for (const element of this.scene.getElementsIncludingDeleted()) {
if (
selectedElementIds.includes(element.id) ||
this.state.selectedElementIds[element.id] ||
// case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired
(element.id === hitElement?.id &&
@@ -3637,11 +3453,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
const nextSceneElements = [...nextElements, ...elementsToAppend];
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
nextSceneElements,
elementsToAppend,
@@ -3698,9 +3509,10 @@ class App extends React.Component<AppProps, AppState> {
if (isBindingElement(draggingElement)) {
// When creating a linear element by dragging
this.maybeSuggestBindingsForLinearElementAtCoords(
this.maybeSuggestBindingForLinearElementAtCursor(
draggingElement,
[pointerCoords],
"end",
pointerCoords,
this.state.startBoundElement,
);
}
@@ -3711,15 +3523,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.elementType === "selection") {
pointerDownState.boxSelection.hasOccurred = true;
const elements = this.scene.getElements();
if (
!event.shiftKey &&
// allows for box-selecting points (without shift)
!this.state.editingLinearElement &&
isSomeElementSelected(elements, this.state)
) {
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
if (pointerDownState.withCmdOrCtrl && pointerDownState.hit.element) {
this.setState((prevState) =>
selectGroupsForSelectedElements(
@@ -3740,43 +3545,33 @@ class App extends React.Component<AppProps, AppState> {
});
}
}
// box-select line editor points
if (this.state.editingLinearElement) {
LinearElementEditor.handleBoxSelection(
event,
this.state,
this.setState.bind(this),
);
// regular box-select
} else {
const elementsWithinSelection = getElementsWithinSelection(
elements,
draggingElement,
);
this.setState((prevState) =>
selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
...prevState.selectedElementIds,
...elementsWithinSelection.reduce((map, element) => {
map[element.id] = true;
return map;
}, {} as any),
...(pointerDownState.hit.element
? {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
[pointerDownState.hit.element.id]:
!elementsWithinSelection.length,
}
: null),
},
const elementsWithinSelection = getElementsWithinSelection(
elements,
draggingElement,
);
this.setState((prevState) =>
selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
...prevState.selectedElementIds,
...elementsWithinSelection.reduce((map, element) => {
map[element.id] = true;
return map;
}, {} as any),
...(pointerDownState.hit.element
? {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
[pointerDownState.hit.element.id]:
!elementsWithinSelection.length,
}
: null),
},
this.scene.getElements(),
),
);
}
},
this.scene.getElements(),
),
);
}
});
}
@@ -3838,32 +3633,19 @@ class App extends React.Component<AppProps, AppState> {
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
if (this.state.editingImageElement) {
ImageEditor.handlePointerUp(this.state.editingImageElement);
}
// Handle end of dragging a point of a linear element, might close a loop
// and sets binding element
if (this.state.editingLinearElement) {
if (
!pointerDownState.boxSelection.hasOccurred &&
(pointerDownState.hit?.element?.id !==
this.state.editingLinearElement.elementId ||
!pointerDownState.hit.hasHitElementInside)
) {
this.actionManager.executeAction(actionFinalize);
} else {
const editingLinearElement = LinearElementEditor.handlePointerUp(
childEvent,
this.state.editingLinearElement,
this.state,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({
editingLinearElement,
suggestedBindings: [],
});
}
const editingLinearElement = LinearElementEditor.handlePointerUp(
childEvent,
this.state.editingLinearElement,
this.state,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({
editingLinearElement,
suggestedBindings: [],
});
}
}
@@ -4045,14 +3827,9 @@ class App extends React.Component<AppProps, AppState> {
if (
hitElement &&
!pointerDownState.drag.hasOccurred &&
!pointerDownState.hit.wasAddedToSelection &&
// if we're editing a line, pointerup shouldn't switch selection if
// box selected
(!this.state.editingLinearElement ||
!pointerDownState.boxSelection.hasOccurred)
!pointerDownState.hit.wasAddedToSelection
) {
// when inside line editor, shift selects points instead
if (childEvent.shiftKey && !this.state.editingLinearElement) {
if (childEvent.shiftKey) {
if (this.state.selectedElementIds[hitElement.id]) {
if (isSelectedViaGroup(this.state, hitElement)) {
// We want to unselect all groups hitElement is part of
@@ -4096,7 +3873,6 @@ class App extends React.Component<AppProps, AppState> {
} else {
// add element to selection while
// keeping prev elements selected
this.setState((_prevState) => ({
selectedElementIds: {
..._prevState.selectedElementIds,
@@ -4227,19 +4003,30 @@ class App extends React.Component<AppProps, AppState> {
const existingFileData = this.files[fileId];
if (!existingFileData?.dataURL) {
try {
imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
});
if (!(await hasTransparentPixels(imageFile))) {
const _imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG,
outputType: MIME_TYPES.jpg,
});
if (_imageFile.size > MAX_ALLOWED_FILE_BYTES) {
imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
outputType: MIME_TYPES.jpg,
});
} else {
imageFile = _imageFile;
}
} else {
imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER,
});
}
} catch (error: any) {
console.error("error trying to resing image file on insertion", error);
}
if (imageFile.size > MAX_ALLOWED_FILE_BYTES) {
throw new Error(
t("errors.fileTooBig", {
maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`,
}),
);
throw new Error(t("errors.fileTooBig"));
}
}
@@ -4578,43 +4365,32 @@ class App extends React.Component<AppProps, AppState> {
});
};
private maybeSuggestBindingsForLinearElementAtCoords = (
private maybeSuggestBindingForLinearElementAtCursor = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
startOrEnd: "start" | "end",
pointerCoords: {
x: number;
y: number;
}[],
},
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): void => {
if (!pointerCoords.length) {
return;
}
const suggestedBindings = pointerCoords.reduce(
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene,
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.push(hoveredBindableElement);
}
return acc;
},
[],
const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords,
this.scene,
);
this.setState({ suggestedBindings });
this.setState({
suggestedBindings:
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
? [hoveredBindableElement]
: [],
});
};
private maybeSuggestBindingForAll(
+4 -11
View File
@@ -3,22 +3,15 @@ import OpenColor from "open-color";
import "./Card.scss";
export const Card: React.FC<{
color: keyof OpenColor | "primary";
color: keyof OpenColor;
}> = ({ children, color }) => {
return (
<div
className="Card"
style={{
["--card-color" as any]:
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
["--card-color-darker" as any]:
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
["--card-color" as any]: OpenColor[color][7],
["--card-color-darker" as any]: OpenColor[color][8],
["--card-color-darkest" as any]: OpenColor[color][9],
}}
>
{children}
+2 -2
View File
@@ -6,14 +6,14 @@ import "./CheckboxItem.scss";
export const CheckboxItem: React.FC<{
checked: boolean;
onChange: (checked: boolean, event: React.MouseEvent) => void;
onChange: (checked: boolean) => void;
className?: string;
}> = ({ children, checked, onChange, className }) => {
return (
<div
className={clsx("Checkbox", className, { "is-checked": checked })}
onClick={(event) => {
onChange(!checked, event);
onChange(!checked);
(
(event.currentTarget as HTMLDivElement).querySelector(
".Checkbox-box",
+6 -12
View File
@@ -7,7 +7,6 @@ import { AppState } from "../types";
import {
isImageElement,
isLinearElement,
isTextBindableContainer,
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils";
@@ -61,18 +60,13 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.rotate");
}
if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (isTextBindableContainer(selectedElements[0])) {
return t("hints.bindTextToElement");
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
+1 -1
View File
@@ -22,7 +22,7 @@
align-items: center;
justify-content: center;
&:focus-visible {
&:focus {
outline: transparent;
background-color: var(--button-gray-2);
& svg {
+1 -1
View File
@@ -102,7 +102,7 @@ const ImageExportModal = ({
const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
? getSelectedElements(elements, appState)
: elements;
useEffect(() => {
+1 -1
View File
@@ -3,7 +3,7 @@
--padding: 0;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
+4 -13
View File
@@ -19,6 +19,7 @@ import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { HintViewer } from "./HintViewer";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu";
@@ -34,9 +35,6 @@ import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
@@ -270,7 +268,7 @@ const LayerUI = ({
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
pendingElements={getSelectedElements(elements, appState)}
onClose={closeLibrary}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
@@ -307,12 +305,7 @@ const LayerUI = ({
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": zenModeEnabled,
})}
>
<Stack.Row gap={1}>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
@@ -321,9 +314,7 @@ const LayerUI = ({
/>
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": zenModeEnabled,
})}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer
appState={appState}
+4 -4
View File
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
}> = ({ appState, setAppState }) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library",
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_medium`,
{
"is-mobile": isMobile,
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},
)}
title={`${capitalizeString(t("toolBar.library"))} — 0`}
style={{ marginInlineStart: "var(--space-factor)" }}
>
<input
className="ToolIcon_type_checkbox"
+3 -42
View File
@@ -18,7 +18,6 @@ import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@@ -237,10 +236,6 @@ export const LibraryMenu = ({
],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{showPublishLibraryDialog && (
@@ -276,44 +271,10 @@ export const LibraryMenu = ({
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]);
}
setLastSelectedItem(id);
onToggle={(id) => {
if (!selectedItems.includes(id)) {
setSelectedItems([...selectedItems, id]);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
+6 -7
View File
@@ -21,7 +21,6 @@ import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants";
const LibraryMenuItems = ({
libraryItems,
@@ -52,7 +51,7 @@ const LibraryMenuItems = ({
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onToggle: (id: LibraryItem["id"]) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
@@ -213,8 +212,10 @@ const LibraryMenuItems = ({
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={(id, event) => {
onToggle(id, event);
onToggle={() => {
if (params.item?.id) {
onToggle(params.item.id);
}
}}
/>
</Stack.Col>
@@ -292,9 +293,7 @@ const LibraryMenuItems = ({
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
+2 -7
View File
@@ -99,13 +99,8 @@
margin-top: -10px;
pointer-events: none;
}
.library-unit:hover .library-unit__adder {
fill: $oc-blue-7;
}
.library-unit:active .library-unit__adder {
animation: none;
transform: scale(0.8);
fill: $oc-black;
.library-unit--hover .library-unit__adder {
color: $oc-blue-7;
}
.library-unit__active {
+6 -19
View File
@@ -8,15 +8,12 @@ import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
// fa-plus
const PLUS_ICON = (
<svg viewBox="0 0 1792 1792">
<path
d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
style={{
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
fill="currentColor"
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
/>
</svg>
);
@@ -36,7 +33,7 @@ export const LibraryUnit = ({
isPending?: boolean;
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onToggle: (id: string) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -87,17 +84,7 @@ export const LibraryUnit = ({
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
}
}
: undefined
}
onClick={!!elements || !!isPending ? onClick : undefined}
onDragStart={(event) => {
setIsHovered(false);
event.dataTransfer.setData(
@@ -110,7 +97,7 @@ export const LibraryUnit = ({
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
onChange={() => onToggle(id)}
className="library-unit__checkbox"
/>
)}
+2 -3
View File
@@ -10,7 +10,6 @@ type LockIconProps = {
checked: boolean;
onChange?(): void;
zenModeEnabled?: boolean;
isMobile?: boolean;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
@@ -43,10 +42,10 @@ export const LockButton = (props: LockIconProps) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
"zen-mode-visibility--hidden": props.zenModeEnabled,
},
)}
title={`${props.title} — Q`}
+3 -8
View File
@@ -64,8 +64,8 @@ export const MobileMenu = ({
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1} className="App-toolbar-container">
<Island padding={1} className="App-toolbar">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
@@ -85,13 +85,8 @@ export const MobileMenu = ({
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
/>
<LibraryButton
appState={appState}
setAppState={setAppState}
isMobile
/>
<LibraryButton appState={appState} setAppState={setAppState} />
</Stack.Row>
{libraryMenu}
</Stack.Col>
+57 -83
View File
@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useEffect, useState } from "react";
import OpenColor from "open-color";
import oc from "open-color";
import { Dialog } from "./Dialog";
import { t } from "../i18n";
@@ -7,19 +7,16 @@ import { t } from "../i18n";
import { ToolButton } from "./ToolButton";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { exportToCanvas } from "../packages/utils";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { exportToBlob } from "../packages/utils";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import { ExcalidrawElement } from "../element/types";
import { newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getCommonBoundingBox } from "../element/bounds";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils";
interface PublishLibraryDataParams {
authorName: string;
@@ -58,75 +55,6 @@ const importPublishLibDataFromStorage = () => {
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
const BOX_PADDING = Math.round(BOX_SIZE / 16);
const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2);
const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW);
const canvas = document.createElement("canvas");
canvas.width =
rows[0].length * BOX_SIZE +
(rows[0].length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
canvas.height =
rows.length * BOX_SIZE +
(rows.length + 1) * (BOX_PADDING * 2) -
BOX_PADDING * 2;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = OpenColor.white;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw items
// ---------------------------------------------------------------------------
for (const [index, item] of libraryItems.entries()) {
const itemCanvas = await exportToCanvas({
elements: item.elements,
files: null,
maxWidthOrHeight: BOX_SIZE,
});
const { width, height } = itemCanvas;
// draw item
// -------------------------------------------------------------------------
const rowOffset =
Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
const colOffset =
(index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2);
ctx.drawImage(
itemCanvas,
colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING,
rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING,
);
// draw item border
// -------------------------------------------------------------------------
ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = OpenColor.gray[4];
ctx.strokeRect(
colOffset + BOX_PADDING / 2,
rowOffset + BOX_PADDING / 2,
BOX_SIZE + BOX_PADDING,
BOX_SIZE + BOX_PADDING,
);
}
return await resizeImageFile(
new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }),
{
outputType: MIME_TYPES.jpg,
maxWidthOrHeight: 5000,
},
);
};
const PublishLibrary = ({
onClose,
libraryItems,
@@ -201,12 +129,59 @@ const PublishLibrary = ({
setIsSubmitting(false);
return;
}
const elements: ExcalidrawElement[] = [];
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
clonedLibItems.forEach((libItem) => {
const boundingBox = getCommonBoundingBox(libItem.elements);
const width = boundingBox.maxX - boundingBox.minX + 30;
const height = boundingBox.maxY - boundingBox.minY + 30;
const offset = {
x: prevBoundingBox.maxX - boundingBox.minX,
y: prevBoundingBox.maxY - boundingBox.minY,
};
const previewImage = await generatePreviewImage(clonedLibItems);
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
element = mutateElement(element, {
x: element.x + offset.x + 15,
y: element.y + offset.y + 15,
});
return element;
});
const items = [
...itemsWithUpdatedCoords,
newElement({
type: "rectangle",
width,
height,
x: prevBoundingBox.maxX,
y: prevBoundingBox.maxY,
strokeColor: "#ced4da",
backgroundColor: "transparent",
strokeStyle: "solid",
opacity: 100,
roughness: 0,
strokeSharpness: "sharp",
fillStyle: "solid",
strokeWidth: 1,
}),
];
elements.push(...items);
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
});
const png = await exportToBlob({
elements,
mimeType: "image/png",
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
const libContent: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
version: 2,
source: EXPORT_SOURCE,
libraryItems: clonedLibItems,
};
@@ -215,8 +190,7 @@ const PublishLibrary = ({
const formData = new FormData();
formData.append("excalidrawLib", lib);
formData.append("previewImage", previewImage);
formData.append("previewImageType", previewImage.type);
formData.append("excalidrawPng", png!);
formData.append("title", libraryData.name);
formData.append("authorName", libraryData.authorName);
formData.append("githubHandle", libraryData.githubHandle);
+28 -21
View File
@@ -8,7 +8,17 @@
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor);
user-select: none;
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
.ToolIcon--plain {
@@ -19,20 +29,6 @@
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
}
}
.ToolIcon__icon {
width: 2.5rem;
height: 2.5rem;
@@ -42,11 +38,7 @@
justify-content: center;
align-items: center;
border-radius: var(--border-radius-lg);
& + .ToolIcon__label {
margin-inline-start: 0;
}
border-radius: var(--space-factor);
svg {
position: relative;
@@ -54,6 +46,10 @@
fill: var(--icon-fill-color);
color: var(--icon-fill-color);
}
& + .ToolIcon__label {
margin-inline-start: 0;
}
}
.ToolIcon__label {
@@ -83,7 +79,7 @@
margin: 0;
font-size: inherit;
&:focus-visible {
&:focus {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -125,7 +121,7 @@
}
}
&:focus-visible + .ToolIcon__icon {
&:focus + .ToolIcon__icon {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -145,6 +141,10 @@
background-color: transparent;
}
&:focus {
box-shadow: none;
}
.ToolIcon__icon {
background-color: var(--button-gray-1);
&:hover {
@@ -159,6 +159,13 @@
}
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__keybinding {
position: absolute;
bottom: 2px;
-112
View File
@@ -1,112 +0,0 @@
@import "open-color/open-color.scss";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
.excalidraw {
.App-toolbar-container {
.ToolIcon_type_floating {
@include toolbarButtonColorStates;
&:not(.is-mobile) {
.ToolIcon__icon {
padding: 1px;
background-color: var(--island-bg-color);
box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%);
border-radius: 50%;
transition: box-shadow 0.5s ease, transform 0.5s ease;
}
}
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:focus-within + .ToolIcon__icon {
// override for custom floating button shadow
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
}
.ToolIcon.ToolIcon__lock {
margin-inline-end: var(--space-factor);
&.ToolIcon_type_floating {
margin-left: 0.1rem;
}
}
.ToolIcon__library {
margin-inline-start: var(--space-factor);
}
&.zen-mode {
.ToolIcon_type_floating {
.ToolIcon__icon {
box-shadow: none;
transform: scale(0.9);
}
.ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) {
& + .ToolIcon__icon {
svg {
fill: $oc-gray-5;
color: $oc-gray-5;
}
}
}
}
}
}
.App-toolbar {
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%);
.ToolIcon {
&:hover {
--icon-fill-color: var(--color-primary-chubb);
--keybinding-color: var(--color-primary-chubb);
}
&:active {
--icon-fill-color: #{$oc-gray-9};
--keybinding-color: #{$oc-gray-9};
}
.ToolIcon__icon {
background: transparent;
border-radius: var(--border-radius-lg);
}
@include toolbarButtonColorStates;
}
&.zen-mode {
.ToolIcon__keybinding,
.HintViewer {
display: none;
}
}
}
&.theme--dark .App-toolbar .ToolIcon:active {
--icon-fill-color: #{$oc-gray-3};
--keybinding-color: #{$oc-gray-3};
}
}
+1
View File
@@ -29,6 +29,7 @@
// wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper {
display: flex;
height: 100%;
}
.excalidraw-tooltip-icon {
+1 -8
View File
@@ -62,15 +62,9 @@ type TooltipProps = {
children: React.ReactNode;
label: string;
long?: boolean;
style?: React.CSSProperties;
};
export const Tooltip = ({
children,
label,
long = false,
style,
}: TooltipProps) => {
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
useEffect(() => {
return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
@@ -90,7 +84,6 @@ export const Tooltip = ({
onPointerLeave={() =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
}
style={style}
>
{children}
</div>
-4
View File
@@ -7,10 +7,6 @@
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
&:empty {
display: none;
}
}
.UserList > * {
+2 -11
View File
@@ -15,9 +15,8 @@ import { THEME } from "../constants";
const activeElementColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
const iconFillColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.black : oc.gray[4];
const handlerColor = (theme: Theme) =>
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
@@ -89,14 +88,6 @@ export const trash = createIcon(
{ width: 448, height: 512 },
);
export const backgroundIcon = createIcon(
<path
fill="currentColor"
d="M512 320s-64 92.65-64 128c0 35.35 28.66 64 64 64s64-28.65 64-64-64-128-64-128zm-9.37-102.94L294.94 9.37C288.69 3.12 280.5 0 272.31 0s-16.38 3.12-22.62 9.37l-81.58 81.58L81.93 4.76c-6.25-6.25-16.38-6.25-22.62 0L36.69 27.38c-6.24 6.25-6.24 16.38 0 22.62l86.19 86.18-94.76 94.76c-37.49 37.48-37.49 98.26 0 135.75l117.19 117.19c18.74 18.74 43.31 28.12 67.87 28.12 24.57 0 49.13-9.37 67.87-28.12l221.57-221.57c12.5-12.5 12.5-32.75.01-45.25zm-116.22 70.97H65.93c1.36-3.84 3.57-7.98 7.43-11.83l13.15-13.15 81.61-81.61 58.6 58.6c12.49 12.49 32.75 12.49 45.24 0s12.49-32.75 0-45.24l-58.6-58.6 58.95-58.95 162.44 162.44-48.34 48.34z"
></path>,
{ width: 576, height: 512 },
);
export const palette = createIcon(
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
+2 -8
View File
@@ -162,7 +162,8 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_JPG = 10000;
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT_OTHER = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
@@ -176,10 +177,3 @@ export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const ENCRYPTION_KEY_BITS = 128;
export const VERSIONS = {
excalidraw: 2,
excalidrawLibrary: 2,
} as const;
export const PADDING = 30;
+8 -10
View File
@@ -180,7 +180,7 @@
}
.buttonList label:focus-within,
input:focus-visible {
input:focus {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -190,14 +190,14 @@
user-select: none;
background-color: var(--button-gray-1);
border: 0;
border-radius: var(--border-radius-md);
border-radius: 4px;
margin: 0.125rem 0;
padding: 0.25rem;
white-space: nowrap;
cursor: pointer;
&:focus-visible {
&:focus {
outline: transparent;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
@@ -217,16 +217,14 @@
.active,
.buttonList label.active {
background-color: var(--color-primary);
--icon-fill-color: #{$oc-white};
background-color: var(--button-gray-2);
&:hover {
background-color: var(--color-primary-darker);
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--color-primary-darkest);
background-color: var(--button-gray-3);
}
}
@@ -236,7 +234,7 @@
justify-content: center;
align-items: center;
svg {
width: 35px;
width: 36px;
height: 14px;
padding: 2px;
opacity: 0.6;
@@ -313,7 +311,7 @@
}
.App-menu_top {
grid-template-columns: auto max-content auto;
grid-template-columns: 1fr auto 1fr;
grid-gap: 4px;
align-items: flex-start;
cursor: default;
+3 -19
View File
@@ -12,7 +12,7 @@
--dialog-border-color: #{$oc-gray-6};
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: #{$oc-gray-9};
--icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white};
@@ -32,20 +32,10 @@
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--space-factor: 0.25rem;
--text-primary-color: #{$oc-gray-8};
--color-primary: #6965db;
--color-primary-chubb: #625ee0; // to offset Chubb illusion
--color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1;
--color-primary-light: #e2e1fc;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
&.theme--dark {
background: $oc-black;
@@ -81,13 +71,7 @@
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--shadow-island: 1px 1px 5px #{transparentize($oc-black, 0.7)};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--text-primary-color: #{$oc-gray-4};
--color-primary: #5650f0;
--color-primary-chubb: #726dff; // to offset Chubb illusion
--color-primary-darker: #4b46d8;
--color-primary-darkest: #3e39be;
--color-primary-light: #3f3d64;
}
}
+3 -1
View File
@@ -271,6 +271,8 @@ export const resizeImageFile = async (
};
}
const fileType = file.type;
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
}
@@ -279,7 +281,7 @@ export const resizeImageFile = async (
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
file.name,
{
type: opts.outputType || file.type,
type: fileType,
},
);
};
+1 -13
View File
@@ -234,19 +234,7 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
let cursor = 0;
// first chunk is the version
const version = dataView(
concatenatedBuffer,
NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
cursor,
);
// If version is outside of the supported versions, throw an error.
// This usually means the buffer wasn't encoded using this API, so we'd only
// waste compute.
if (version > CONCAT_BUFFERS_VERSION) {
throw new Error(`invalid version ${version}`);
}
// first chunk is the version (ignored for now)
cursor += VERSION_DATAVIEW_BYTES;
while (true) {
+32 -3
View File
@@ -1,8 +1,11 @@
import decodePng from "png-chunks-extract";
import extractPngChunks from "png-chunks-extract";
import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { PngChunk } from "../types";
export { extractPngChunks };
// -----------------------------------------------------------------------------
// PNG
@@ -28,7 +31,9 @@ const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
export const getTEXtChunk = async (
blob: Blob,
): Promise<{ keyword: string; text: string } | null> => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
if (metadataChunk) {
return tEXt.decode(metadataChunk.data);
@@ -36,6 +41,28 @@ export const getTEXtChunk = async (
return null;
};
export const findPngChunk = (
chunks: PngChunk[],
name: PngChunk["name"],
/** this makes the search stop before IDAT chunk (before which most
* metadata chunks reside). This is a perf optim. */
breakBeforeIDAT = true,
) => {
let i = 0;
const len = chunks.length;
while (i <= len) {
const chunk = chunks[i];
if (chunk.name === name) {
return chunk;
}
if (breakBeforeIDAT && chunk.name === "IDAT") {
return null;
}
i++;
}
return null;
};
export const encodePngMetadata = async ({
blob,
metadata,
@@ -43,7 +70,9 @@ export const encodePngMetadata = async ({
blob: Blob;
metadata: string;
}) => {
const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
const chunks = extractPngChunks(
new Uint8Array(await blobToArrayBuffer(blob)),
);
const metadataChunk = tEXt.encode(
MIME_TYPES.excalidraw,
+3 -8
View File
@@ -1,11 +1,6 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
VERSIONS,
} from "../constants";
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
@@ -47,7 +42,7 @@ export const serializeAsJSON = (
): string => {
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw,
version: 2,
source: EXPORT_SOURCE,
elements:
type === "local"
@@ -126,7 +121,7 @@ export const isValidLibrary = (json: any) => {
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
version: 2,
source: EXPORT_SOURCE,
libraryItems,
};
+9 -15
View File
@@ -10,7 +10,11 @@ import {
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
import {
getElementMap,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
@@ -22,8 +26,6 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp } from "../utils";
import { arrayToMap } from "../utils";
type RestoredAppState = Omit<
AppState,
@@ -64,10 +66,7 @@ const restoreElementWithProperties = <
T extends ExcalidrawElement,
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
element: Required<T> & {
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
},
element: Required<T>,
extra: Pick<
T,
// This extra Pick<T, keyof K> ensure no excess properties are passed.
@@ -101,10 +100,7 @@ const restoreElementWithProperties = <
strokeSharpness:
element.strokeSharpness ??
(isLinearElementType(element.type) ? "round" : "sharp"),
boundElements: element.boundElementIds
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
boundElementIds: element.boundElementIds ?? [],
};
return {
@@ -135,8 +131,6 @@ const restoreElement = (
baseline: element.baseline,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null,
originalText: element.originalText ?? "",
});
case "freedraw": {
return restoreElementWithProperties(element, {
@@ -210,14 +204,14 @@ export const restoreElements = (
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
): ExcalidrawElement[] => {
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const localElementsMap = localElements ? getElementMap(localElements) : null;
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)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
const localElement = localElementsMap?.[element.id];
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
import type { cleanAppStateForExport } from "../appState";
import { VERSIONS } from "../constants";
export interface ExportedDataState {
type: string;
@@ -25,7 +24,7 @@ export interface ImportedDataState {
export interface ExportedLibraryData {
type: string;
version: typeof VERSIONS.excalidrawLibrary;
version: 2;
source: string;
libraryItems: LibraryItems;
}
+65 -90
View File
@@ -8,11 +8,7 @@ import {
} from "./types";
import { getElementAtPosition } from "../scene";
import { AppState } from "../types";
import {
isBindableElement,
isBindingElement,
isLinearElement,
} from "./typeChecks";
import { isBindableElement, isBindingElement } from "./typeChecks";
import {
bindingBorderTest,
distanceToBindableElement,
@@ -24,7 +20,7 @@ import {
import { mutateElement } from "./mutateElement";
import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { tupleToCoors } from "../utils";
import { KEYS } from "../keys";
export type SuggestedBinding =
@@ -78,9 +74,8 @@ export const bindOrUnbindLinearElement = (
.getNonDeletedElements(onlyUnbound)
.forEach((element) => {
mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
boundElementIds: element.boundElementIds?.filter(
(id) => id !== linearElement.id,
),
});
});
@@ -185,16 +180,11 @@ const bindLinearElement = (
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
} as PointBinding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
}),
});
}
mutateElement(hoveredElement, {
boundElementIds: Array.from(
new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
),
});
};
// Don't bind both ends of a simple segment
@@ -294,56 +284,52 @@ export const updateBoundElements = (
newSize?: { width: number; height: number };
},
) => {
const boundLinearElements = (changedElement.boundElements ?? []).filter(
(el) => el.type === "arrow",
);
if (boundLinearElements.length === 0) {
const boundElementIds = changedElement.boundElementIds ?? [];
if (boundElementIds.length === 0) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
Scene.getScene(changedElement)!
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
.forEach((element) => {
if (!isLinearElement(element)) {
return;
}
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
(
Scene.getScene(changedElement)!.getNonDeletedElements(
boundElementIds,
) as NonDeleted<ExcalidrawLinearElement>[]
).forEach((linearElement) => {
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElementIds are stale
if (!doesNeedUpdate(linearElement, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
linearElement.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
linearElement.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
mutateElement(linearElement, { startBinding, endBinding });
return;
}
updateBoundPoint(
linearElement,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
linearElement,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
};
const doesNeedUpdate = (
@@ -415,17 +401,10 @@ const updateBoundPoint = (
newEdgePoint = intersections[0];
}
}
LinearElementEditor.movePoints(
LinearElementEditor.movePoint(
linearElement,
[
{
index: edgePointIndex,
point: LinearElementEditor.pointFromAbsoluteCoords(
linearElement,
newEdgePoint,
),
},
],
edgePointIndex,
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
);
};
@@ -573,11 +552,11 @@ export const fixBindingsAfterDuplication = (
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
oldElements.forEach((oldElement) => {
const { boundElements } = oldElement;
if (boundElements != null && boundElements.length > 0) {
boundElements.forEach((boundElement) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
allBoundElementIds.add(boundElement.id);
const { boundElementIds } = oldElement;
if (boundElementIds != null && boundElementIds.length > 0) {
boundElementIds.forEach((boundElementId) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
allBoundElementIds.add(boundElementId);
}
});
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
@@ -621,16 +600,12 @@ export const fixBindingsAfterDuplication = (
sceneElements
.filter(({ id }) => allBindableElementIds.has(id))
.forEach((bindableElement) => {
const { boundElements } = bindableElement;
if (boundElements != null && boundElements.length > 0) {
const { boundElementIds } = bindableElement;
if (boundElementIds != null && boundElementIds.length > 0) {
mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) =>
oldIdToDuplicatedId.has(boundElement.id)
? {
id: oldIdToDuplicatedId.get(boundElement.id)!,
type: boundElement.type,
}
: boundElement,
boundElementIds: boundElementIds.map(
(boundElementId) =>
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
),
});
}
@@ -663,9 +638,9 @@ export const fixBindingsAfterDeletion = (
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => {
if (!deletedElementIds.has(element.id)) {
boundElementIds.add(element.id);
deletedElement.boundElementIds?.forEach((id) => {
if (!deletedElementIds.has(id)) {
boundElementIds.add(id);
}
});
}
+11 -12
View File
@@ -31,9 +31,7 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { isImageElement } from "./typeChecks";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -45,9 +43,9 @@ const isElementDraggableFromInside = (
if (element.type === "freedraw") {
return true;
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
(isTransparent(element.backgroundColor) && hasBoundTextElement(element));
const isDraggableFromInside = element.backgroundColor !== "transparent";
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
@@ -85,18 +83,19 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
);
};
export const isHittingElementNotConsideringBoundingBox = (
const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement,
appState: AppState,
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
const check = isTextElement(element)
? isStrictlyInside
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
const check =
element.type === "text"
? isStrictlyInside
: isElementDraggableFromInside(element)
? isInsideCheck
: isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check });
};
+18 -49
View File
@@ -6,13 +6,13 @@ import { getPerfectElementSize } from "./sizeHelpers";
import Scene from "../scene/Scene";
import { NonDeletedExcalidrawElement } from "./types";
import { PointerDownState } from "../types";
import { getBoundTextElementId } from "./textElement";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[],
pointerX: number,
pointerY: number,
scene: Scene,
lockDirection: boolean = false,
distanceX: number = 0,
distanceY: number = 0,
@@ -20,61 +20,30 @@ export const dragSelectedElements = (
const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => {
updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState,
element,
offset,
);
if (!element.groupIds.length) {
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)!.getElement(boundTextElementId);
updateElementCoords(
lockDirection,
distanceX,
distanceY,
pointerDownState,
textElement!,
offset,
);
}
let x: number;
let y: number;
if (lockDirection) {
const lockX = lockDirection && distanceX < distanceY;
const lockY = lockDirection && distanceX > distanceY;
const original = pointerDownState.originalElements.get(element.id);
x = lockX && original ? original.x : element.x + offset.x;
y = lockY && original ? original.y : element.y + offset.y;
} else {
x = element.x + offset.x;
y = element.y + offset.y;
}
mutateElement(element, {
x,
y,
});
updateBoundElements(element, {
simultaneouslyUpdated: selectedElements,
});
});
};
const updateElementCoords = (
lockDirection: boolean,
distanceX: number,
distanceY: number,
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
offset: { x: number; y: number },
) => {
let x: number;
let y: number;
if (lockDirection) {
const lockX = lockDirection && distanceX < distanceY;
const lockY = lockDirection && distanceX > distanceY;
const original = pointerDownState.originalElements.get(element.id);
x = lockX && original ? original.x : element.x + offset.x;
y = lockY && original ? original.y : element.y + offset.y;
} else {
x = element.x + offset.x;
y = element.y + offset.y;
}
mutateElement(element, {
x,
y,
});
};
export const getDragOffsetXY = (
selectedElements: NonDeletedExcalidrawElement[],
x: number,
+76 -10
View File
@@ -3,6 +3,7 @@
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants";
import { getDataURL } from "../data/blob";
import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks";
@@ -110,15 +111,80 @@ export const normalizeSVG = async (SVGString: string) => {
}
};
export const imageFromImageData = (imagedata: ImageData) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = imagedata.width;
canvas.height = imagedata.height;
ctx.putImageData(imagedata, 0, 0);
/**
* To improve perf, uses `createImageBitmap` is available. But there are
* quality issues across browsers, so don't use this API where quality matters.
*/
export const speedyImageToCanvas = async (imageFile: Blob | File) => {
let imageSrc: HTMLImageElement | ImageBitmap;
if (
typeof ImageBitmap !== "undefined" &&
ImageBitmap.prototype &&
ImageBitmap.prototype.close &&
window.createImageBitmap
) {
imageSrc = await window.createImageBitmap(imageFile);
} else {
imageSrc = await loadHTMLImageElement(await getDataURL(imageFile));
}
const { width, height } = imageSrc;
const image = new Image();
const dataURL = canvas.toDataURL() as DataURL;
image.src = dataURL;
return { image, dataURL };
const canvas = document.createElement("canvas");
canvas.height = height;
canvas.width = width;
const context = canvas.getContext("2d")!;
context.drawImage(imageSrc, 0, 0, width, height);
if (typeof ImageBitmap !== "undefined" && imageSrc instanceof ImageBitmap) {
imageSrc.close();
}
return { canvas, context, width, height };
};
/**
* Does its best at figuring out if an image (PNG) has any (semi)transparent
* pixels. If not PNG, always returns false.
*/
export const hasTransparentPixels = async (imageFile: Blob | File) => {
if (imageFile.type !== MIME_TYPES.png) {
return false;
}
const { findPngChunk, extractPngChunks } = await import("../data/image");
const buffer = await imageFile.arrayBuffer();
const chunks = extractPngChunks(new Uint8Array(buffer));
// early exit if tRNS not found and IHDR states no support for alpha
// -----------------------------------------------------------------------
const IHDR = findPngChunk(chunks, "IHDR");
if (
IHDR &&
IHDR.data[9] !== 4 &&
IHDR.data[9] !== 6 &&
!findPngChunk(chunks, "tRNS")
) {
return false;
}
// otherwise loop through pixels to check if there's any actually
// (semi)transparent pixel
// -----------------------------------------------------------------------
const { width, height, context } = await speedyImageToCanvas(imageFile);
{
const { data } = context.getImageData(0, 0, width, height);
const len = data.byteLength;
let i = 3;
while (i <= len) {
if (data[i] !== 255) {
return true;
}
i += 4;
}
}
return false;
};
-112
View File
@@ -1,112 +0,0 @@
import { distance2d } from "../math";
import Scene from "../scene/Scene";
import {
ExcalidrawImageElement,
InitializedExcalidrawImageElement,
} from "./types";
export type EditingImageElement = {
editorType: "alpha";
elementId: ExcalidrawImageElement["id"];
origImageData: Readonly<ImageData>;
imageData: ImageData;
pointerDownState: {
screenX: number;
screenY: number;
sampledPixel: readonly [number, number, number, number] | null;
};
};
const getElement = (id: EditingImageElement["elementId"]) => {
const element = Scene.getScene(id)?.getNonDeletedElement(id);
if (element) {
return element as InitializedExcalidrawImageElement;
}
return null;
};
export class ImageEditor {
static handlePointerDown(
editingElement: EditingImageElement,
scenePointer: { x: number; y: number },
) {
const imageElement = getElement(editingElement.elementId);
if (imageElement) {
if (
scenePointer.x >= imageElement.x &&
scenePointer.x <= imageElement.x + imageElement.width &&
scenePointer.y >= imageElement.y &&
scenePointer.y <= imageElement.y + imageElement.height
) {
editingElement.pointerDownState.screenX = scenePointer.x;
editingElement.pointerDownState.screenY = scenePointer.y;
const { width, height, data } = editingElement.origImageData;
const imageOffsetX = Math.round(
(scenePointer.x - imageElement.x) * (width / imageElement.width),
);
const imageOffsetY = Math.round(
(scenePointer.y - imageElement.y) * (height / imageElement.height),
);
const sampledPixel = [
data[(imageOffsetY * width + imageOffsetX) * 4 + 0],
data[(imageOffsetY * width + imageOffsetX) * 4 + 1],
data[(imageOffsetY * width + imageOffsetX) * 4 + 2],
data[(imageOffsetY * width + imageOffsetX) * 4 + 3],
] as const;
editingElement.pointerDownState.sampledPixel = sampledPixel;
}
}
}
static handlePointerMove(
editingElement: EditingImageElement,
scenePointer: { x: number; y: number },
) {
const { sampledPixel } = editingElement.pointerDownState;
if (sampledPixel) {
const { screenX, screenY } = editingElement.pointerDownState;
const distance = distance2d(
scenePointer.x,
scenePointer.y,
screenX,
screenY,
);
const { width, height, data } = editingElement.origImageData;
const newImageData = new ImageData(width, height);
for (let x = 0; x < width; ++x) {
for (let y = 0; y < height; ++y) {
if (
Math.abs(sampledPixel[0] - data[(y * width + x) * 4 + 0]) +
Math.abs(sampledPixel[1] - data[(y * width + x) * 4 + 1]) +
Math.abs(sampledPixel[2] - data[(y * width + x) * 4 + 2]) <
distance
) {
newImageData.data[(y * width + x) * 4 + 0] = 0;
newImageData.data[(y * width + x) * 4 + 1] = 255;
newImageData.data[(y * width + x) * 4 + 2] = 0;
newImageData.data[(y * width + x) * 4 + 3] = 0;
} else {
for (let p = 0; p < 4; ++p) {
newImageData.data[(y * width + x) * 4 + p] =
data[(y * width + x) * 4 + p];
}
}
}
}
return newImageData;
}
}
static handlePointerUp(editingElement: EditingImageElement) {
editingElement.pointerDownState.sampledPixel = null;
editingElement.origImageData = editingElement.imageData;
}
}
+9
View File
@@ -59,6 +59,15 @@ export {
} from "./sizeHelpers";
export { showSelectedShapeActions } from "./showSelectedShapeActions";
export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
elements.reduce(
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
acc[element.id] = element;
return acc;
},
{},
);
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
elements.reduce((acc, el) => acc + el.version, 0);
+100 -415
View File
@@ -25,19 +25,11 @@ export class LinearElementEditor {
public elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
};
/** indices */
public selectedPointsIndices: readonly number[] | null;
public pointerDownState: Readonly<{
prevSelectedPointsIndices: readonly number[] | null;
/** index */
lastClickedPoint: number;
}>;
public activePointIndex: number | null;
/** whether you're dragging a point */
public isDragging: boolean;
public lastUncommittedPoint: Point | null;
public pointerOffset: Readonly<{ x: number; y: number }>;
public pointerOffset: { x: number; y: number };
public startBindingElement: ExcalidrawBindableElement | null | "keep";
public endBindingElement: ExcalidrawBindableElement | null | "keep";
@@ -48,16 +40,12 @@ export class LinearElementEditor {
Scene.mapElementToScene(this.elementId, scene);
LinearElementEditor.normalizePoints(element);
this.selectedPointsIndices = null;
this.activePointIndex = null;
this.lastUncommittedPoint = null;
this.isDragging = false;
this.pointerOffset = { x: 0, y: 0 };
this.startBindingElement = "keep";
this.endBindingElement = "keep";
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
};
}
// ---------------------------------------------------------------------------
@@ -78,58 +66,6 @@ export class LinearElementEditor {
return null;
}
static handleBoxSelection(
event: PointerEvent,
appState: AppState,
setState: React.Component<any, AppState>["setState"],
) {
if (
!appState.editingLinearElement ||
appState.draggingElement?.type !== "selection"
) {
return false;
}
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(appState.draggingElement);
const pointsSceneCoords =
LinearElementEditor.getPointsGlobalCoordinates(element);
const nextSelectedPoints = pointsSceneCoords.reduce(
(acc: number[], point, index) => {
if (
(point[0] >= selectionX1 &&
point[0] <= selectionX2 &&
point[1] >= selectionY1 &&
point[1] <= selectionY2) ||
(event.shiftKey && selectedPointsIndices?.includes(index))
) {
acc.push(index);
}
return acc;
},
[],
);
setState({
editingLinearElement: {
...editingLinearElement,
selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints
: null,
},
});
}
/** @returns whether point was dragged */
static handlePointDragging(
appState: AppState,
@@ -138,27 +74,21 @@ export class LinearElementEditor {
scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[],
startOrEnd: "start" | "end",
) => void,
): boolean {
if (!appState.editingLinearElement) {
return false;
}
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId, isDragging } =
editingLinearElement;
const { activePointIndex, elementId, isDragging } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[
editingLinearElement.pointerDownState.lastClickedPoint
] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) {
if (activePointIndex != null && activePointIndex > -1) {
if (isDragging === false) {
setState({
editingLinearElement: {
@@ -168,79 +98,18 @@ export class LinearElementEditor {
});
}
const newDraggingPointPosition = LinearElementEditor.createPointAt(
const newPoint = LinearElementEditor.createPointAt(
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.gridSize,
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt(
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.gridSize,
)
: ([
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
return {
index: pointIndex,
point: newPointPosition,
isDragging:
pointIndex ===
editingLinearElement.pointerDownState.lastClickedPoint,
};
}),
);
// suggest bindings for first and last point if selected
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
if (isBindingElement(element)) {
const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0];
if (firstSelectedIndex === 0) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[0],
),
),
);
}
const lastSelectedIndex =
selectedPointsIndices[selectedPointsIndices.length - 1];
if (lastSelectedIndex === element.points.length - 1) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[lastSelectedIndex],
),
),
);
}
if (coords.length) {
maybeSuggestBinding(element, coords);
}
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
}
return true;
}
return false;
}
@@ -249,79 +118,45 @@ export class LinearElementEditor {
editingLinearElement: LinearElementEditor,
appState: AppState,
): LinearElementEditor {
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
const { elementId, activePointIndex, isDragging } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return editingLinearElement;
}
const bindings: Partial<
Pick<
InstanceType<typeof LinearElementEditor>,
"startBindingElement" | "endBindingElement"
>
> = {};
if (isDragging && selectedPointsIndices) {
for (const selectedPoint of selectedPointsIndices) {
if (
selectedPoint === 0 ||
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, [
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
]);
}
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
selectedPoint!,
),
),
Scene.getScene(element)!,
)
: null;
bindings[
selectedPoint === 0 ? "startBindingElement" : "endBindingElement"
] = bindingElement;
}
let binding = {};
if (
isDragging &&
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoint(
element,
activePointIndex,
activePointIndex === 0
? element.points[element.points.length - 1]
: element.points[0],
);
}
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
activePointIndex!,
),
),
Scene.getScene(element)!,
)
: null;
binding = {
[activePointIndex === 0 ? "startBindingElement" : "endBindingElement"]:
bindingElement,
};
}
return {
...editingLinearElement,
...bindings,
// if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift,
// toggle the point.
selectedPointsIndices:
isDragging || event.shiftKey
? !isDragging &&
event.shiftKey &&
pointerDownState.prevSelectedPointsIndices?.includes(
pointerDownState.lastClickedPoint,
)
? selectedPointsIndices &&
selectedPointsIndices.filter(
(pointIndex) =>
pointIndex !== pointerDownState.lastClickedPoint,
)
: selectedPointsIndices
: selectedPointsIndices?.includes(pointerDownState.lastClickedPoint)
? [pointerDownState.lastClickedPoint]
: selectedPointsIndices,
...binding,
isDragging: false,
pointerOffset: { x: 0, y: 0 },
};
@@ -371,12 +206,7 @@ export class LinearElementEditor {
setState({
editingLinearElement: {
...appState.editingLinearElement,
pointerDownState: {
prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: -1,
},
selectedPointsIndices: [element.points.length - 1],
activePointIndex: element.points.length - 1,
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
@@ -429,28 +259,10 @@ export class LinearElementEditor {
element.angle,
);
const nextSelectedPointsIndices =
clickedPointIndex > -1 || event.shiftKey
? event.shiftKey ||
appState.editingLinearElement.selectedPointsIndices?.includes(
clickedPointIndex,
)
? normalizeSelectedPoints([
...(appState.editingLinearElement.selectedPointsIndices || []),
clickedPointIndex,
])
: [clickedPointIndex]
: null;
setState({
editingLinearElement: {
...appState.editingLinearElement,
pointerDownState: {
prevSelectedPointsIndices:
appState.editingLinearElement.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
},
selectedPointsIndices: nextSelectedPointsIndices,
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
pointerOffset: targetPoint
? {
x: scenePointer.x - targetPoint[0],
@@ -480,7 +292,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]);
LinearElementEditor.movePoint(element, points.length - 1, "delete");
}
return { ...editingLinearElement, lastUncommittedPoint: null };
}
@@ -493,14 +305,13 @@ export class LinearElementEditor {
);
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
LinearElementEditor.movePoint(
element,
element.points.length - 1,
newPoint,
);
} else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
LinearElementEditor.movePoint(element, "new", newPoint);
}
return {
@@ -509,21 +320,6 @@ export class LinearElementEditor {
};
}
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
point: Point,
) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
}
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
) {
@@ -643,122 +439,22 @@ export class LinearElementEditor {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
}
static duplicateSelectedPoints(appState: AppState) {
if (!appState.editingLinearElement) {
return false;
}
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element || selectedPointsIndices === null) {
return false;
}
const { points } = element;
const nextSelectedIndices: number[] = [];
let pointAddedToEnd = false;
let indexCursor = -1;
const nextPoints = points.reduce((acc: Point[], point, index) => {
++indexCursor;
acc.push(point);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
const nextPoint = points[index + 1];
if (!nextPoint) {
pointAddedToEnd = true;
}
acc.push(
nextPoint
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
: [point[0], point[1]],
);
nextSelectedIndices.push(indexCursor + 1);
++indexCursor;
}
return acc;
}, []);
mutateElement(element, { points: nextPoints });
// temp hack to ensure the line doesn't move when adding point to the end,
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
},
]);
}
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
},
};
}
static deletePoints(
static movePointByOffset(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
pointIndex: number,
offset: { x: number; y: number },
) {
let offsetX = 0;
let offsetY = 0;
const isDeletingOriginPoint = pointIndices.includes(0);
// if deleting first point, make the next to be [0,0] and recalculate
// positions of the rest with respect to it
if (isDeletingOriginPoint) {
const firstNonDeletedPoint = element.points.find((point, idx) => {
return !pointIndices.includes(idx);
});
if (firstNonDeletedPoint) {
offsetX = firstNonDeletedPoint[0];
offsetY = firstNonDeletedPoint[1];
}
}
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
);
}
return acc;
}, []);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
const [x, y] = element.points[pointIndex];
LinearElementEditor.movePoint(element, pointIndex, [
x + offset.x,
y + offset.y,
]);
}
static addPoints(
static movePoint(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[],
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
pointIndex: number | "new",
targetPosition: Point | "delete",
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const { points } = element;
@@ -771,50 +467,49 @@ export class LinearElementEditor {
let offsetX = 0;
let offsetY = 0;
const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
let nextPoints: (readonly [number, number])[];
if (targetPosition === "delete") {
// remove point
if (pointIndex === "new") {
throw new Error("invalid args in movePoint");
}
nextPoints = points.slice();
nextPoints.splice(pointIndex, 1);
if (pointIndex === 0) {
// if deleting first point, make the next to be [0,0] and recalculate
// positions of the rest with respect to it
offsetX = nextPoints[0][0];
offsetY = nextPoints[0][1];
nextPoints = nextPoints.map((point, idx) => {
if (idx === 0) {
return [0, 0];
}
return [point[0] - offsetX, point[1] - offsetY];
});
}
} else if (pointIndex === "new") {
nextPoints = [...points, targetPosition];
} else {
const deltaX = targetPosition[0] - points[pointIndex][0];
const deltaY = targetPosition[1] - points[pointIndex][1];
nextPoints = points.map((point, idx) => {
if (idx === pointIndex) {
if (idx === 0) {
offsetX = deltaX;
offsetY = deltaY;
return point;
}
offsetX = 0;
offsetY = 0;
if (selectedOriginPoint) {
offsetX =
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
offsetY =
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
return [point[0] + deltaX, point[1] + deltaY] as const;
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
});
}
const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx);
if (selectedPointData) {
if (selectedOriginPoint) {
return point;
}
const deltaX =
selectedPointData.point[0] - points[selectedPointData.index][0];
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return [point[0] + deltaX, point[1] + deltaY] as const;
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
});
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
otherUpdates,
);
}
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
offsetX: number,
offsetY: number,
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const nextCoords = getElementPointsCoords(
element,
nextPoints,
@@ -822,7 +517,7 @@ export class LinearElementEditor {
);
const prevCoords = getElementPointsCoords(
element,
element.points,
points,
element.strokeSharpness || "round",
);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@@ -841,13 +536,3 @@ export class LinearElementEditor {
});
}
}
const normalizeSelectedPoints = (
points: (number | null)[],
): number[] | null => {
let nextPoints = [
...new Set(points.filter((p) => p !== null && p !== -1)),
] as number[];
nextPoints = nextPoints.sort((a, b) => a - b);
return nextPoints.length ? nextPoints : null;
};
+1 -5
View File
@@ -4,7 +4,6 @@ import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@@ -93,7 +92,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.informMutation();
@@ -128,14 +126,13 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return {
...element,
...updates,
updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
};
};
/**
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
* Mutates element and updates `version` & `versionNonce`.
*
* NOTE: does not trigger re-render.
*/
@@ -145,6 +142,5 @@ export const bumpVersion = (
) => {
element.version = (version ?? element.version) + 1;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
return element;
};
+35 -81
View File
@@ -11,28 +11,23 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawRectangleElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp } from "../utils";
import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement";
import { newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import { measureText } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import Scene from "../scene/Scene";
import { PADDING } from "../constants";
type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted">,
| "width"
| "height"
| "angle"
| "groupIds"
| "boundElements"
| "boundElementIds"
| "seed"
| "version"
| "versionNonce"
@@ -55,36 +50,32 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0,
groupIds = [],
strokeSharpness,
boundElements = null,
boundElementIds = null,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
const element = {
id: rest.id || randomId(),
type,
x,
y,
width,
height,
angle,
strokeColor,
backgroundColor,
fillStyle,
strokeWidth,
strokeStyle,
roughness,
opacity,
groupIds,
strokeSharpness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElements,
updated: getUpdatedTimestamp(),
};
return element;
};
) => ({
id: rest.id || randomId(),
type,
x,
y,
width,
height,
angle,
strokeColor,
backgroundColor,
fillStyle,
strokeWidth,
strokeStyle,
roughness,
opacity,
groupIds,
strokeSharpness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElementIds,
});
export const newElement = (
opts: {
@@ -122,7 +113,6 @@ export const newTextElement = (
fontFamily: FontFamilyValues;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId?: ExcalidrawRectangleElement["id"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const metrics = measureText(opts.text, getFontString(opts));
@@ -140,8 +130,6 @@ export const newTextElement = (
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: opts.text,
},
{},
);
@@ -158,25 +146,18 @@ const getAdjustedDimensions = (
height: number;
baseline: number;
} => {
const maxWidth = element.containerId ? element.width : null;
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), maxWidth);
} = measureText(nextText, getFontString(element));
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
if (
textAlign === "center" &&
verticalAlign === "middle" &&
!element.containerId
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
maxWidth,
);
if (textAlign === "center" && verticalAlign === "middle") {
const prevMetrics = measureText(element.text, getFontString(element));
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
@@ -213,22 +194,6 @@ const getAdjustedDimensions = (
);
}
// make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions
if (isBoundToContainer(element)) {
const container = Scene.getScene(element)!.getElement(element.containerId)!;
let height = container.height;
let width = container.width;
if (nextHeight > height - PADDING * 2) {
height = nextHeight + PADDING * 2;
}
if (nextWidth > width - PADDING * 2) {
width = nextWidth + PADDING * 2;
}
if (height !== container.height || width !== container.width) {
mutateElement(container, { height, width });
}
}
return {
width: nextWidth,
height: nextHeight,
@@ -240,22 +205,12 @@ const getAdjustedDimensions = (
export const updateTextElement = (
element: ExcalidrawTextElement,
{
text,
isDeleted,
originalText,
}: { text: string; isDeleted?: boolean; originalText: string },
updateDimensions: boolean,
{ text, isDeleted }: { text: string; isDeleted?: boolean },
): ExcalidrawTextElement => {
const dimensions = updateDimensions
? getAdjustedDimensions(element, text)
: undefined;
return newElementWith(element, {
text,
originalText,
isDeleted: isDeleted ?? element.isDeleted,
...dimensions,
...getAdjustedDimensions(element, text),
});
};
@@ -382,7 +337,6 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} else {
copy.id = randomId();
}
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
+3 -43
View File
@@ -25,7 +25,7 @@ import {
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getFontString } from "../utils";
import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
TransformHandleType,
@@ -33,13 +33,6 @@ import {
TransformHandleDirection,
} from "./transformHandles";
import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
getBoundTextElementId,
handleBindTextResize,
measureText,
} from "./textElement";
export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) {
@@ -139,7 +132,6 @@ export const transformElements = (
pointerX,
pointerY,
);
handleBindTextResize(selectedElements, transformHandleType);
return true;
}
}
@@ -162,11 +154,6 @@ const rotateSingleElement = (
}
angle = normalizeAngle(angle);
mutateElement(element, { angle });
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
mutateElement(textElement!, { angle });
}
};
// used in DEV only
@@ -285,7 +272,6 @@ const measureFontSizeFromWH = (
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? element.width : null,
);
return {
size: nextFontSize,
@@ -427,9 +413,6 @@ export const resizeSingleElement = (
element.width,
element.height,
);
const boundTextElementId = getBoundTextElementId(element);
const boundsCurrentWidth = esx2 - esx1;
const boundsCurrentHeight = esy2 - esy1;
@@ -490,11 +473,6 @@ export const resizeSingleElement = (
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// don't allow resize to negative dimensions when text is bounded to container
if ((newBoundsWidth < 0 || newBoundsHeight < 0) && boundTextElementId) {
return;
}
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleDirection)) {
@@ -587,16 +565,9 @@ export const resizeSingleElement = (
],
});
}
let minWidth = 0;
if (boundTextElementId) {
const boundTextElement = Scene.getScene(element)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
}
if (
resizedElement.width > minWidth &&
resizedElement.width !== 0 &&
resizedElement.height !== 0 &&
Number.isFinite(resizedElement.x) &&
Number.isFinite(resizedElement.y)
@@ -605,7 +576,6 @@ export const resizeSingleElement = (
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
handleBindTextResize([element], transformHandleDirection);
}
};
@@ -677,7 +647,7 @@ const resizeMultipleElements = (
const width = element.width * scale;
const height = element.height * scale;
let font: { fontSize?: number; baseline?: number } = {};
if (isTextElement(element)) {
if (element.type === "text") {
const nextFont = measureFontSizeFromWH(element, width, height);
if (nextFont === null) {
return null;
@@ -758,16 +728,6 @@ const rotateMultipleElements = (
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement =
Scene.getScene(element)!.getElement(boundTextElementId)!;
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
});
};
+3 -380
View File
@@ -1,389 +1,12 @@
import { getFontString, arrayToMap } from "../utils";
import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawTextElement,
FontString,
NonDeletedExcalidrawElement,
} from "./types";
import { measureText, getFontString } from "../utils";
import { ExcalidrawTextElement } from "./types";
import { mutateElement } from "./mutateElement";
import { PADDING } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
let maxWidth;
if (element.containerId) {
maxWidth = element.width;
}
const metrics = measureText(
element.originalText,
getFontString(element),
maxWidth,
);
const metrics = measureText(element.text, getFontString(element));
mutateElement(element, {
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
});
};
export const bindTextToShapeAfterDuplication = (
sceneElements: ExcalidrawElement[],
oldElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
): void => {
const sceneElementMap = arrayToMap(sceneElements) as Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
oldElements.forEach((element) => {
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId)!;
mutateElement(
sceneElementMap.get(newElementId) as ExcalidrawBindableElement,
{
boundElements: element.boundElements?.concat({
type: "text",
id: newTextElementId,
}),
},
);
mutateElement(
sceneElementMap.get(newTextElementId) as ExcalidrawTextElement,
{
containerId: newElementId,
},
);
}
});
};
export const handleBindTextResize = (
elements: readonly NonDeletedExcalidrawElement[],
transformHandleType: MaybeTransformHandleType,
) => {
elements.forEach((element) => {
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
if (textElement && textElement.text) {
if (!element) {
return;
}
let text = textElement.text;
let nextHeight = textElement.height;
let containerHeight = element.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
let minCharWidthTillNow = 0;
if (text) {
minCharWidthTillNow = getMinCharWidth(getFontString(textElement));
// check if the diff has exceeded min char width needed
const diff = Math.abs(
element.width - textElement.width + PADDING * 2,
);
if (diff >= minCharWidthTillNow) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
element.width,
);
}
}
const dimensions = measureText(
text,
getFontString(textElement),
element.width,
);
nextHeight = dimensions.height;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > element.height - PADDING * 2) {
containerHeight = nextHeight + 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"
? element.y - diff
: element.y;
mutateElement(element, {
height: containerHeight,
y: updatedY,
});
}
const updatedY = element.y + containerHeight / 2 - nextHeight / 2;
mutateElement(textElement, {
text,
// preserve padding and set width correctly
width: element.width - PADDING * 2,
height: nextHeight,
x: element.x + PADDING,
y: updatedY,
baseline: nextBaseLine,
});
}
}
});
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
container.style.width = `${String(maxWidth)}px`;
container.style.maxWidth = `${String(maxWidth)}px`;
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`;
container.style.whiteSpace = "pre-wrap";
}
document.body.appendChild(container);
container.innerText = text;
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
container.appendChild(span);
// Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight;
const width = container.offsetWidth;
const height = container.offsetHeight;
document.body.removeChild(container);
return { width, height, baseline };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
export const getApproxLineHeight = (font: FontString) => {
return measureText(DUMMY_TEXT, font, null).height;
};
let canvas: HTMLCanvasElement | undefined;
const getTextWidth = (text: string, font: FontString) => {
if (!canvas) {
canvas = document.createElement("canvas");
}
const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font;
const metrics = canvas2dContext.measureText(text);
return metrics.width;
};
export const wrapText = (
text: string,
font: FontString,
containerWidth: number,
) => {
const maxWidth = containerWidth - PADDING * 2;
const lines: Array<string> = [];
const originalLines = text.split("\n");
const spaceWidth = getTextWidth(" ", font);
originalLines.forEach((originalLine) => {
const words = originalLine.split(" ");
// This means its newline so push it
if (words.length === 1 && words[0] === "") {
lines.push(words[0]);
} else {
let currentLine = "";
let currentLineWidthTillNow = 0;
let index = 0;
while (index < words.length) {
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
if (currentLine) {
lines.push(currentLine);
}
currentLine = "";
currentLineWidthTillNow = 0;
while (words[index].length > 0) {
const currentChar = words[index][0];
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(1);
if (currentLineWidthTillNow >= maxWidth) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
lines.push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
if (currentLineWidthTillNow === maxWidth) {
currentLine = "";
currentLineWidthTillNow = 0;
}
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth > maxWidth) {
lines.push(currentLine);
currentLine = "";
currentLineWidthTillNow = 0;
} else {
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line
currentLine += " ";
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getTextWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) {
lines.push(currentLine);
currentLineWidthTillNow = 0;
currentLine = "";
break;
}
index++;
currentLine += `${word} `;
}
if (currentLineWidthTillNow === maxWidth) {
currentLine = "";
currentLineWidthTillNow = 0;
}
}
}
if (currentLine) {
// only remove last trailing space which we have added when joining words
if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1);
}
lines.push(currentLine);
}
}
});
return lines.join("\n");
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getTextWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][ascii];
};
const updateCache = (char: string, font: FontString) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font][ascii]) {
cachedCharWidth[font][ascii] = calculate(char, font);
}
};
const clearCacheforFont = (font: FontString) => {
cachedCharWidth[font] = [];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
return {
calculate,
updateCache,
clearCacheforFont,
getCache,
};
})();
export const getApproxMinLineWidth = (font: FontString) => {
return measureText(DUMMY_TEXT.split("").join("\n"), font).width + PADDING * 2;
};
export const getApproxMinLineHeight = (font: FontString) => {
return getApproxLineHeight(font) + PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
// Generally lower case is used so converting to lower case
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
const batchLength = 6;
let index = 0;
let widthTillNow = 0;
let str = "";
while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength);
str += batch;
widthTillNow += getTextWidth(str, font);
if (index === dummyText.length - 1) {
index = 0;
}
index = index + batchLength;
}
while (widthTillNow > width) {
str = str.substr(0, str.length - 1);
widthTillNow = getTextWidth(str, font);
}
return str.length;
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id;
};
+43 -247
View File
@@ -1,25 +1,10 @@
import { CODES, KEYS } from "../keys";
import {
isWritableElement,
getFontString,
viewportCoordsToSceneCoords,
getFontFamilyString,
} from "../utils";
import { isWritableElement, getFontString } from "../utils";
import Scene from "../scene/Scene";
import { isBoundToContainer, isTextElement } from "./typeChecks";
import { CLASSES, PADDING } from "../constants";
import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawTextElement,
} from "./types";
import { isTextElement } from "./typeChecks";
import { CLASSES } from "../constants";
import { ExcalidrawElement } from "./types";
import { AppState } from "../types";
import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
wrapText,
} from "./textElement";
const normalizeText = (text: string) => {
return (
@@ -63,154 +48,55 @@ export const textWysiwyg = ({
id: ExcalidrawElement["id"];
appState: AppState;
onChange?: (text: string) => void;
onSubmit: (data: {
text: string;
viaKeyboard: boolean;
originalText: string;
}) => void;
onSubmit: (data: { text: string; viaKeyboard: boolean }) => void;
getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawElement;
canvas: HTMLCanvasElement | null;
excalidrawContainer: HTMLDivElement | null;
}) => {
const textPropertiesUpdated = (
updatedElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement,
) => {
const currentFont = editable.style.fontFamily.replaceAll('"', "");
if (
getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !==
currentFont
) {
return true;
}
if (`${updatedElement.fontSize}px` !== editable.style.fontSize) {
return true;
}
return false;
};
let originalContainerHeight: number;
let approxLineHeight = isTextElement(element)
? getApproxLineHeight(getFontString(element))
: 0;
const updateWysiwygStyle = () => {
const updatedElement = Scene.getScene(element)?.getElement(id);
if (updatedElement && isTextElement(updatedElement)) {
let coordX = updatedElement.x;
let coordY = updatedElement.y;
let container = updatedElement?.containerId
? Scene.getScene(updatedElement)!.getElement(updatedElement.containerId)
: null;
let maxWidth = updatedElement.width;
let maxHeight = updatedElement.height;
let width = updatedElement.width;
let height = updatedElement.height;
if (container && updatedElement.containerId) {
const propertiesUpdated = textPropertiesUpdated(
updatedElement,
editable,
);
if (propertiesUpdated) {
const currentContainer = Scene.getScene(updatedElement)?.getElement(
updatedElement.containerId,
) as ExcalidrawBindableElement;
approxLineHeight = isTextElement(updatedElement)
? getApproxLineHeight(getFontString(updatedElement))
: 0;
if (updatedElement.height > currentContainer.height - PADDING * 2) {
const nextHeight = updatedElement.height + PADDING * 2;
originalContainerHeight = nextHeight;
mutateElement(container, { height: nextHeight });
container = { ...container, height: nextHeight };
}
editable.style.height = `${updatedElement.height}px`;
}
if (!originalContainerHeight) {
originalContainerHeight = container.height;
}
maxWidth = container.width - PADDING * 2;
maxHeight = container.height - PADDING * 2;
width = maxWidth;
height = Math.min(height, maxHeight);
// The coordinates of text box set a distance of
// 30px to preserve padding
coordX = container.x + PADDING;
// autogrow container height if text exceeds
if (editable.clientHeight > maxHeight) {
const diff = Math.min(
editable.clientHeight - maxHeight,
approxLineHeight,
);
mutateElement(container, { height: container.height + diff });
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
container.height > originalContainerHeight &&
editable.clientHeight < maxHeight
) {
const diff = Math.min(
maxHeight - editable.clientHeight,
approxLineHeight,
);
mutateElement(container, { height: container.height - diff });
}
// Start pushing text upward until a diff of 30px (padding)
// is reached
else {
const lines = editable.clientHeight / approxLineHeight;
// For some reason the scrollHeight gets set to twice the lineHeight
// when you start typing for first time and thus line count is 2
// hence this check
if (lines > 2 || propertiesUpdated) {
// vertically center align the text
coordY =
container.y + container.height / 2 - editable.clientHeight / 2;
}
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
const [viewportX, viewportY] = getViewportCoords(
updatedElement.x,
updatedElement.y,
);
const { textAlign, angle } = updatedElement;
editable.value = updatedElement.originalText || updatedElement.text;
const lines = updatedElement.originalText.split("\n");
const lineHeight = updatedElement.containerId
? approxLineHeight
: updatedElement.height / lines.length;
if (!container) {
maxWidth =
(appState.offsetLeft + appState.width - viewportX - 8) /
appState.zoom.value -
// margin-right of parent if any
Number(
getComputedStyle(
excalidrawContainer?.parentNode as Element,
).marginRight.slice(0, -2),
);
}
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.offsetTop + appState.height - viewportY) /
appState.zoom.value;
editable.value = updatedElement.text;
const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = updatedElement.height / lines.length;
const maxWidth =
(appState.offsetLeft + appState.width - viewportX - 8) /
appState.zoom.value -
// margin-right of parent if any
Number(
getComputedStyle(
excalidrawContainer?.parentNode as Element,
).marginRight.slice(0, -2),
);
Object.assign(editable.style, {
font: getFontString(updatedElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`,
width: `${width}px`,
height: `${Math.max(editable.clientHeight, updatedElement.height)}px`,
width: `${updatedElement.width}px`,
height: `${updatedElement.height}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transform: getTransform(width, height, angle, appState, maxWidth),
transform: getTransform(
updatedElement.width,
updatedElement.height,
angle,
appState,
maxWidth,
),
textAlign,
color: updatedElement.strokeColor,
opacity: updatedElement.opacity / 100,
filter: "var(--theme-filter)",
maxWidth: `${maxWidth}px`,
maxHeight: `${editorMaxHeight}px`,
});
}
};
@@ -224,13 +110,6 @@ export const textWysiwyg = ({
editable.wrap = "off";
editable.classList.add("excalidraw-wysiwyg");
let whiteSpace = "pre";
let wordBreak = "normal";
if (isBoundToContainer(element)) {
whiteSpace = "pre-wrap";
wordBreak = "break-word";
}
Object.assign(editable.style, {
position: "absolute",
display: "inline-block",
@@ -243,21 +122,16 @@ export const textWysiwyg = ({
resize: "none",
background: "transparent",
overflow: "hidden",
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace: "pre",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
wordBreak,
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace,
overflowWrap: "break-word",
});
updateWysiwygStyle();
if (onChange) {
editable.oninput = () => {
if (isBoundToContainer(element)) {
editable.style.height = "auto";
editable.style.height = `${editable.scrollHeight}px`;
}
onChange(normalizeText(editable.value));
};
}
@@ -300,7 +174,7 @@ export const textWysiwyg = ({
const linesStartIndices = getSelectedLinesStartIndices();
let value = editable.value;
linesStartIndices.forEach((startIndex: number) => {
linesStartIndices.forEach((startIndex) => {
const startValue = value.slice(0, startIndex);
const endValue = value.slice(startIndex);
@@ -400,63 +274,9 @@ export const textWysiwyg = ({
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
// wysiwyg on update
cleanup();
const updateElement = Scene.getScene(element)?.getElement(element.id);
if (!updateElement) {
return;
}
let wrappedText = "";
if (isTextElement(updateElement) && updateElement?.containerId) {
const container = Scene.getScene(updateElement)!.getElement(
updateElement.containerId,
) as ExcalidrawBindableElement;
if (container) {
wrappedText = wrapText(
editable.value,
getFontString(updateElement),
container.width,
);
const { x, y } = viewportCoordsToSceneCoords(
{
clientX: Number(editable.style.left.slice(0, -2)),
clientY: Number(editable.style.top.slice(0, -2)),
},
appState,
);
if (isTextElement(updateElement) && updateElement.containerId) {
if (editable.value) {
mutateElement(updateElement, {
y: y + appState.offsetTop,
height: Number(editable.style.height.slice(0, -2)),
width: Number(editable.style.width.slice(0, -2)),
x: x + appState.offsetLeft,
});
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: element.id,
}),
});
}
} else {
mutateElement(container, {
boundElements: container.boundElements?.filter(
(ele) => ele.type !== "text",
),
});
}
}
}
} else {
wrappedText = editable.value;
}
onSubmit({
text: normalizeText(wrappedText),
text: normalizeText(editable.value),
viaKeyboard: submittedViaKeyboard,
originalText: editable.value,
});
};
@@ -485,45 +305,26 @@ export const textWysiwyg = ({
editable.remove();
};
const bindBlurEvent = (event?: MouseEvent) => {
const bindBlurEvent = () => {
window.removeEventListener("pointerup", bindBlurEvent);
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
const target = event?.target;
const isTargetColorPicker =
target instanceof HTMLInputElement &&
target.closest(".color-picker-input") &&
isWritableElement(target);
setTimeout(() => {
editable.onblur = handleSubmit;
if (target && isTargetColorPicker) {
target.onblur = () => {
editable.focus();
};
}
// case: clicking on the same property → no change → no update → no focus
if (!isTargetColorPicker) {
editable.focus();
}
editable.focus();
});
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const isTargetColorPicker =
event.target instanceof HTMLInputElement &&
event.target.closest(".color-picker-input") &&
isWritableElement(event.target);
if (
((event.target instanceof HTMLElement ||
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) ||
isTargetColorPicker
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)
) {
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
@@ -536,12 +337,7 @@ export const textWysiwyg = ({
// handle updates of textElement properties of editing element
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
updateWysiwygStyle();
const isColorPickerActive = !!document.activeElement?.closest(
".color-picker-input",
);
if (!isColorPickerActive) {
editable.focus();
}
editable.focus();
});
// ---------------------------------------------------------------------------
+1 -2
View File
@@ -3,7 +3,6 @@ import { ExcalidrawElement, PointerType } from "./types";
import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math";
import { Zoom } from "../types";
import { isTextElement } from ".";
export type TransformHandleDirection =
| "n"
@@ -243,7 +242,7 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
}
}
} else if (isTextElement(element)) {
} else if (element.type === "text") {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
}
+1 -30
View File
@@ -7,7 +7,6 @@ import {
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
} from "./types";
export const isGenericElement = (
@@ -86,18 +85,7 @@ export const isBindableElement = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
(element.type === "text" && !element.containerId))
);
};
export const isTextBindableContainer = (element: ExcalidrawElement | null) => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image")
element.type === "text")
);
};
@@ -112,20 +100,3 @@ export const isExcalidrawElement = (element: any): boolean => {
element?.type === "line"
);
};
export const hasBoundTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
isBindableElement(element) &&
!!element.boundElements?.some(({ type }) => type === "text")
);
};
export const isBoundToContainer = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElementWithContainer => {
return (
element !== null && isTextElement(element) && element.containerId !== null
);
};
+2 -15
View File
@@ -43,15 +43,8 @@ type _ExcalidrawElementBase = Readonly<{
/** List of groups the element belongs to.
Ordered from deepest to shallowest. */
groupIds: readonly GroupId[];
/** other elements that are bound to this element */
boundElements:
| readonly Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>[]
| null;
/** epoch (ms) timestamp of last element update */
updated: number;
/** Ids of (linear) elements that are bound to this element. */
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
}>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
@@ -121,8 +114,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;
originalText: string;
}>;
export type ExcalidrawBindableElement =
@@ -132,10 +123,6 @@ export type ExcalidrawBindableElement =
| ExcalidrawTextElement
| ExcalidrawImageElement;
export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawGenericElement["id"];
} & ExcalidrawTextElement;
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
@@ -78,7 +78,7 @@ export const ExportToExcalidrawPlus: React.FC<{
onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => {
return (
<Card color="primary">
<Card color="indigo">
<div className="Card-icon">{excalidrawPlusIcon}</div>
<h2>Excalidraw+</h2>
<div className="Card-details">
+1 -5
View File
@@ -199,11 +199,7 @@ export const encodeFilesForUpload = async ({
});
if (buffer.byteLength > maxBytes) {
throw new Error(
t("errors.fileTooBig", {
maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
}),
);
throw new Error(t("errors.fileTooBig"));
}
processedFiles.push({
+1 -9
View File
@@ -11,15 +11,7 @@ import { MIME_TYPES } from "../../constants";
// private
// -----------------------------------------------------------------------------
let FIREBASE_CONFIG: Record<string, any>;
try {
FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
} catch (error: any) {
console.warn(
`Error JSON parsing firebase config. Supplied value: ${process.env.REACT_APP_FIREBASE_CONFIG}`,
);
FIREBASE_CONFIG = {};
}
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
null;
+37 -65
View File
@@ -1,6 +1,6 @@
import { compressData, decompressData } from "../../data/encode";
import {
decryptData,
encryptData,
generateEncryptionKey,
IV_LENGTH_BYTES,
} from "../../data/encryption";
@@ -109,45 +109,9 @@ export const getCollaborationLink = (data: {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
};
/**
* Decodes shareLink data using the legacy buffer format.
* @deprecated
*/
const legacy_decodeFromBackend = async ({
buffer,
decryptionKey,
}: {
buffer: ArrayBuffer;
decryptionKey: string;
}) => {
let decrypted: ArrayBuffer;
try {
// Buffer should contain both the IV (fixed length) and encrypted data
const iv = buffer.slice(0, IV_LENGTH_BYTES);
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
} catch (error: any) {
// Fixed IV (old format, backward compatibility)
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
decrypted = await decryptData(fixedIv, buffer, decryptionKey);
}
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
const data: ImportedDataState = JSON.parse(string);
return {
elements: data.elements || null,
appState: data.appState || null,
};
};
const importFromBackend = async (
id: string,
decryptionKey: string,
privateKey: string,
): Promise<ImportedDataState> => {
try {
const response = await fetch(`${BACKEND_V2_GET}${id}`);
@@ -158,28 +122,28 @@ const importFromBackend = async (
}
const buffer = await response.arrayBuffer();
let decrypted: ArrayBuffer;
try {
const { data: decodedBuffer } = await decompressData(
new Uint8Array(buffer),
{
decryptionKey,
},
);
const data: ImportedDataState = JSON.parse(
new TextDecoder().decode(decodedBuffer),
);
return {
elements: data.elements || null,
appState: data.appState || null,
};
// Buffer should contain both the IV (fixed length) and encrypted data
const iv = buffer.slice(0, IV_LENGTH_BYTES);
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
decrypted = await decryptData(new Uint8Array(iv), encrypted, privateKey);
} catch (error: any) {
console.warn(
"error when decoding shareLink data using the new format:",
error,
);
return legacy_decodeFromBackend({ buffer, decryptionKey });
// Fixed IV (old format, backward compatibility)
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
decrypted = await decryptData(fixedIv, buffer, privateKey);
}
// We need to convert the decrypted array buffer to a string
const string = new window.TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
const data: ImportedDataState = JSON.parse(string);
return {
elements: data.elements || null,
appState: data.appState || null,
};
} catch (error: any) {
window.alert(t("alerts.importBackendFailed"));
console.error(error);
@@ -224,14 +188,20 @@ export const exportToBackend = async (
appState: AppState,
files: BinaryFiles,
) => {
const encryptionKey = await generateEncryptionKey("string");
const json = serializeAsJSON(elements, appState, files, "database");
const encoded = new TextEncoder().encode(json);
const payload = await compressData(
new TextEncoder().encode(
serializeAsJSON(elements, appState, files, "database"),
),
{ encryptionKey },
);
const cryptoKey = await generateEncryptionKey("cryptoKey");
const { encryptedBuffer, iv } = await encryptData(cryptoKey, encoded);
// Concatenate IV with encrypted data (IV does not have to be secret).
const payloadBlob = new Blob([iv.buffer, encryptedBuffer]);
const payload = await new Response(payloadBlob).arrayBuffer();
// We use jwk encoding to be able to extract just the base64 encoded key.
// We will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey);
try {
const filesMap = new Map<FileId, BinaryFileData>();
@@ -241,6 +211,8 @@ export const exportToBackend = async (
}
}
const encryptionKey = exportedKey.k!;
const filesToUpload = await encodeFilesForUpload({
files: filesMap,
encryptionKey,
@@ -249,7 +221,7 @@ export const exportToBackend = async (
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: payload.buffer,
body: payload,
});
const json = await response.json();
if (json.id) {
+1 -1
View File
@@ -9,7 +9,7 @@
.encrypted-icon {
border-radius: var(--space-factor);
color: var(--color-primary);
color: var(--icon-green-fill-color);
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
+4 -4
View File
@@ -1,3 +1,5 @@
// import type {PngChunk} from "./types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Document {
fonts?: {
@@ -54,8 +56,6 @@ type NonOptional<T> = Exclude<T, undefined>;
// PNG encoding/decoding
// -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
declare module "png-chunk-text" {
function encode(
name: string,
@@ -64,11 +64,11 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string };
}
declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array;
function encode(chunks: import("./types").PngChunk[]): Uint8Array;
export = encode;
}
declare module "png-chunks-extract" {
function extract(buffer: Uint8Array): TEXtChunk[];
function extract(buffer: Uint8Array): import("./types").PngChunk[];
export = extract;
}
// -----------------------------------------------------------------------------
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "مشاركة",
"showStroke": "إظهار منتقي لون الخط",
"showBackground": "إظهار منتقي لون الخلفية",
"toggleTheme": "غير النمط",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "غير النمط"
},
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
@@ -137,11 +135,7 @@
"zenMode": "وضع التأمل",
"exitZenMode": "إلغاء الوضع الليلى",
"cancel": "إلغاء",
"clear": "مسح",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": "مسح"
},
"alerts": {
"clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
"lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال",
"lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "تعذر عرض المعاينة",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
"link": "مشاركة المدونة في التشفير من النهاية إلى النهاية في Excalidraw"
@@ -345,7 +289,6 @@
"width": "العرض"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "نسخت الانماط.",
"copyToClipboard": "نسخ إلى الحافظة.",
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغة PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "Нулиране на платно",
@@ -137,11 +135,7 @@
"zenMode": "Режим Zen",
"exitZenMode": "Спиране на Zen режим",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките",
"lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите",
"lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "Невъзможност за показване на preview",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.",
"link": ""
@@ -345,7 +289,6 @@
"width": "Широчина"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Копирани стилове.",
"copyToClipboard": "Копирано в клипборда.",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "",
@@ -137,11 +135,7 @@
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "",
"link": ""
@@ -345,7 +289,6 @@
"width": ""
},
"toast": {
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Comparteix",
"showStroke": "Mostra el selector de color del traç",
"showBackground": "Mostra el selector de color de fons",
"toggleTheme": "Activa o desactiva el tema",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Activa o desactiva el tema"
},
"buttons": {
"clearReset": "Neteja el llenç",
@@ -137,11 +135,7 @@
"zenMode": "Mode zen",
"exitZenMode": "Surt de mode zen",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "S'esborrarà tot el llenç. N'esteu segur?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Fes doble clic o premi Enter per editar punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el punt, CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Selecciona un punt per moure o eliminar, o manté premut Alt i fes clic per afegir punts nous",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors dExcalidraw no els veuran mai.",
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
@@ -345,7 +289,6 @@
"width": "Amplada"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "S'han copiat els estils.",
"copyToClipboard": "S'ha copiat al porta-retalls.",
"copyToClipboardAsPng": "S'ha copiat {{exportSelection}} al porta-retalls en format PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Sdílet",
"showStroke": "",
"showBackground": "",
"toggleTheme": "Přepnout tmavý řežim",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Přepnout tmavý řežim"
},
"buttons": {
"clearReset": "",
@@ -137,11 +135,7 @@
"zenMode": "Zen mód",
"exitZenMode": "Opustit zen mód",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "",
"link": ""
@@ -345,7 +289,6 @@
"width": ""
},
"toast": {
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Del",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "",
@@ -137,11 +135,7 @@
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "",
"link": ""
@@ -345,7 +289,6 @@
"width": "Bredde"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Kopieret stilarter.",
"copyToClipboard": "Kopieret til klippebord.",
"copyToClipboardAsPng": "Kopieret {{exportSelection}} til klippebord som PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Teilen",
"showStroke": "Auswahl für Strichfarbe anzeigen",
"showBackground": "Hintergrundfarbe auswählen",
"toggleTheme": "Design umschalten",
"personalLib": "Persönliche Bibliothek",
"excalidrawLib": "Excalidraw-Bibliothek"
"toggleTheme": "Design umschalten"
},
"buttons": {
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
@@ -137,11 +135,7 @@
"zenMode": "Zen-Modus",
"exitZenMode": "Zen-Modus verlassen",
"cancel": "Abbrechen",
"clear": "Löschen",
"remove": "Entfernen",
"publishLibrary": "Veröffentlichen",
"submit": "Absenden",
"confirm": "Bestätigen"
"clear": "Löschen"
},
"alerts": {
"clearReset": "Dies wird die ganze Zeichenfläche löschen. Bist du dir sicher?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden",
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Doppelklicken oder Eingabetaste drücken, um Punkte zu bearbeiten",
"lineEditor_pointSelected": "Drücke Löschen, um Punkt zu entfernen, Strg+D oder Cmd+D zum Duplizieren oder ziehe zum Verschieben",
"lineEditor_nothingSelected": "Wähle einen Punkt zum Verschieben oder Löschen oder halte die Alt-Taste 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",
"publishLibrary": "Veröffentliche deine eigene Bibliothek"
"placeImage": "Klicken, um das Bild zu platzieren oder klicken und ziehen um seine Größe manuell zu setzen"
},
"canvasError": {
"cannotShowPreview": "Vorschau kann nicht angezeigt werden",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Zeichenfläche löschen"
},
"publishDialog": {
"title": "Bibliothek veröffentlichen",
"itemName": "Elementname",
"authorName": "Name des Autors",
"githubUsername": "GitHub-Benutzername",
"twitterUsername": "Twitter-Benutzername",
"libraryName": "Name der Bibliothek",
"libraryDesc": "Beschreibung der Bibliothek",
"website": "Webseite",
"placeholder": {
"authorName": "Dein Name oder Benutzername",
"libraryName": "Name deiner Bibliothek",
"libraryDesc": "Beschreibung deiner Bibliothek, um anderen Nutzern bei der Verwendung zu helfen",
"githubHandle": "GitHub-Handle (optional), damit du die Bibliothek bearbeiten kannst, wenn sie zur Überprüfung eingereicht wurde",
"twitterHandle": "Twitter-Benutzername (optional), damit wir wissen, wen wir bei Werbung über Twitter nennen können",
"website": "Link zu deiner persönlichen Webseite oder zu anderer Seite (optional)"
},
"errors": {
"required": "Erforderlich",
"website": "Gültige URL eingeben"
},
"noteDescription": {
"pre": "Sende deine Bibliothek ein, um in die ",
"link": "öffentliche Bibliotheks-Repository aufgenommen zu werden",
"post": "damit andere Nutzer sie in ihren Zeichnungen verwenden können."
},
"noteGuidelines": {
"pre": "Die Bibliothek muss zuerst manuell freigegeben werden. Bitte lies die ",
"link": "Richtlinien",
"post": " vor dem Absenden. Du benötigst ein GitHub-Konto, um zu kommunizieren und Änderungen vorzunehmen, falls erforderlich, aber es ist nicht unbedingt erforderlich."
},
"noteLicense": {
"pre": "Mit dem Absenden stimmst du zu, dass die Bibliothek unter der ",
"link": "MIT-Lizenz, ",
"post": "die zusammengefasst beinhaltet, dass jeder sie ohne Einschränkungen nutzen kann."
},
"noteItems": "Jedes Bibliothekselement muss einen eigenen Namen haben, damit es gefiltert werden kann. Die folgenden Bibliothekselemente werden hinzugefügt:",
"atleastOneLibItem": "Bitte wähle mindestens ein Bibliothekselement aus, um zu beginnen"
},
"publishSuccessDialog": {
"title": "Bibliothek übermittelt",
"content": "Vielen Dank {{authorName}}. Deine Bibliothek wurde zur Überprüfung eingereicht. Du kannst den Status verfolgen",
"link": "hier"
},
"confirmDialog": {
"resetLibrary": "Bibliothek zurücksetzen",
"removeItemsFromLib": "Ausgewählte Elemente aus der Bibliothek entfernen"
},
"encrypted": {
"tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals.",
"link": "Blogbeitrag über Ende-zu-Ende-Verschlüsselung in Excalidraw"
@@ -345,7 +289,6 @@
"width": "Breite"
},
"toast": {
"addedToLibrary": "Zur Bibliothek hinzugefügt",
"copyStyles": "Formatierungen kopiert.",
"copyToClipboard": "In die Zwischenablage kopiert.",
"copyToClipboardAsPng": "{{exportSelection}} als PNG in die Zwischenablage kopiert\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Κοινοποίηση",
"showStroke": "Εμφάνιση επιλογέα χρωμάτων πινελιάς",
"showBackground": "Εμφάνιση επιλογέα χρώματος φόντου",
"toggleTheme": "Εναλλαγή θέματος",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Εναλλαγή θέματος"
},
"buttons": {
"clearReset": "Επαναφορά του καμβά",
@@ -137,11 +135,7 @@
"zenMode": "Λειτουργία Zεν",
"exitZenMode": "Έξοδος από την λειτουργία Zen",
"cancel": "Ακύρωση",
"clear": "Καθαρισμός",
"remove": "Κατάργηση",
"publishLibrary": "Δημοσίευση",
"submit": "Υποβολή",
"confirm": "Επιβεβαίωση"
"clear": "Καθαρισμός"
},
"alerts": {
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
"invalidSceneUrl": "",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
"lineEditor_pointSelected": "Πιέστε Διαγραφή για να αφαιρέσετε το σημείου, CtrlOrCmd+D για να το αντιγράψετε ή σύρτε το για να το μετακινήσετε",
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για μετακίνηση ή αφαίρεση, ή κρατήστε παρατεταμένα το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
"placeImage": "",
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη"
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Καθαρισμός καμβά"
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "Όνομα δημιουργού",
"githubUsername": "GitHub username",
"twitterUsername": "Twitter username",
"libraryName": "Όνομα βιβλιοθήκης",
"libraryDesc": "",
"website": "Ιστοσελίδα",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": "Εισάγετε μια έγκυρη διεύθυνση URL"
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw.",
"link": "Blog post στην κρυπτογράφηση end-to-end στο Excalidraw"
@@ -345,7 +289,6 @@
"width": "Πλάτος"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Αντιγράφηκαν στυλ.",
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
"copyToClipboardAsPng": "Αντιγράφηκε {{exportSelection}} στο πρόχειρο ως PNG\n({{exportColorScheme}})",
+4 -5
View File
@@ -169,7 +169,7 @@
"errors": {
"unsupportedFileType": "Unsupported file type.",
"imageInsertError": "Couldn't insert image. Try again later...",
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"fileTooBig": "File is too big.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "Invalid SVG."
},
@@ -204,11 +204,10 @@
"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": "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",
"lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text"
"publishLibrary": "Publish your own library"
},
"canvasError": {
"cannotShowPreview": "Cannot show preview",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Compartir",
"showStroke": "Mostrar selector de color de trazo",
"showBackground": "Mostrar el selector de color de fondo",
"toggleTheme": "Alternar tema",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Alternar tema"
},
"buttons": {
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
@@ -137,11 +135,7 @@
"zenMode": "Modo Zen",
"exitZenMode": "Salir del modo Zen",
"cancel": "Cancelar",
"clear": "Borrar",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": "Borrar"
},
"alerts": {
"clearReset": "Esto limpiará todo el lienzo. Estás seguro?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen",
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Doble clic o pulse Enter para editar puntos",
"lineEditor_pointSelected": "Presione Suprimir para eliminar el punto, CtrlOrCmd+D para duplicarlo, o arrástrelo para moverlo",
"lineEditor_nothingSelected": "Selecciona un punto sea para mover o eliminar, o mantén pulsado Alt y haz 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": ""
"placeImage": "Haga clic para colocar la imagen o haga clic y arrastre para establecer su tamaño manualmente"
},
"canvasError": {
"cannotShowPreview": "No se puede mostrar la vista previa",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Borrar lienzo"
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán.",
"link": "Entrada en el blog sobre cifrado de extremo a extremo"
@@ -345,7 +289,6 @@
"width": "Ancho"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Estilos copiados.",
"copyToClipboard": "Copiado en el portapapeles.",
"copyToClipboardAsPng": "Copiado {{exportSelection}} al portapapeles como PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "اشتراک‌گذاری",
"showStroke": "نمایش انتخاب کننده رنگ حاشیه",
"showBackground": "نمایش انتخاب کننده رنگ پس زمینه",
"toggleTheme": "تغییر تم",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "تغییر تم"
},
"buttons": {
"clearReset": "پاکسازی بوم نقاشی",
@@ -137,11 +135,7 @@
"zenMode": "حالت ذن",
"exitZenMode": "خروج از حالت تمرکز",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "این کار کل صفحه را پاک میکند. آیا مطمئنید؟",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "دوبار کلیک کنید یا Enter را فشار دهید تا نقاط را ویرایش کنید",
"lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید.",
"lineEditor_nothingSelected": "یک نقطه را برای جابجایی یا حذف انتخاب کنید یا کلید Alt بگیرید و کلیک کنید تا بتوانید یک نقطه جدید اضافه کنید",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "پیش نمایش نشان داده نمی شود",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند.",
"link": ""
@@ -345,7 +289,6 @@
"width": "عرض"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "کپی سبک.",
"copyToClipboard": "در کلیپ‌بورد کپی شد.",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Jaa",
"showStroke": "Näytä viivan värin valitsin",
"showBackground": "Näytä taustavärin valitsin",
"toggleTheme": "Vaihda teema",
"personalLib": "Oma kirjasto",
"excalidrawLib": "Excalidraw kirjasto"
"toggleTheme": "Vaihda teema"
},
"buttons": {
"clearReset": "Tyhjennä piirtoalue",
@@ -137,11 +135,7 @@
"zenMode": "Zen-tila",
"exitZenMode": "Poistu zen-tilasta",
"cancel": "Peruuta",
"clear": "Pyyhi",
"remove": "Poista",
"publishLibrary": "Julkaise",
"submit": "Lähetä",
"confirm": "Vahvista"
"clear": "Pyyhi"
},
"alerts": {
"clearReset": "Tämä tyhjentää koko piirtoalueen. Jatketaanko?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Teosta ei voitu palauttaa tästä kuvatiedostosta",
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Kaksoisnapauta tai paina Enter muokataksesi pisteitä",
"lineEditor_pointSelected": "Paina Delete poistaaksesi pisteen, Ctrl tai Cmd+D monistaaksesi, tai raahaa liikuttaaksesi",
"lineEditor_nothingSelected": "Valitse liikutettava tai poistettava piste, tai pidä ALT-näppäintä alaspainettuna ja napsauta lisätäksesi uusia pisteitä",
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
"publishLibrary": "Julkaise oma kirjasto"
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti"
},
"canvasError": {
"cannotShowPreview": "Esikatselua ei voitu näyttää",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Pyyhi piirtoalue"
},
"publishDialog": {
"title": "Julkaise kirjasto",
"itemName": "Kohteen nimi",
"authorName": "Tekijän nimi",
"githubUsername": "GitHub-käyttäjätunnus",
"twitterUsername": "Twitter-käyttäjätunnus",
"libraryName": "Kirjaston nimi",
"libraryDesc": "Kirjaston kuvaus",
"website": "Verkkosivu",
"placeholder": {
"authorName": "Nimesi tai käyttäjänimesi",
"libraryName": "Kirjastosi nimi",
"libraryDesc": "Kirjaston kuvaus, joka auttaa ihmisiä ymmärtämään sen käyttötarkoitukset",
"githubHandle": "GitHub-tunnuksesi (valinnainen), jotta voit muokata kirjastoa sen jälkeen kun se on lähetetty tarkastettavaksi",
"twitterHandle": "Twitter-tunnus (valinnainen), jotta tiedämme ketä kiittää kun viestimme Twitterissä",
"website": "Linkki henkilökohtaiselle verkkosivustollesi tai muualle (valinnainen)"
},
"errors": {
"required": "Pakollinen",
"website": "Syötä oikeamuotoinen URL-osoite"
},
"noteDescription": {
"pre": "Lähetä kirjastosi, jotta se voidaan sisällyttää ",
"link": "julkisessa kirjastolistauksessa",
"post": "muiden käyttöön omissa piirrustuksissaan."
},
"noteGuidelines": {
"pre": "Kirjasto on ensin hyväksyttävä manuaalisesti. Ole hyvä ja lue ",
"link": "ohjeet",
"post": " ennen lähettämistä. Tarvitset GitHub-tilin, jotta voit viestiä ja tehdä muutoksia pyydettäessä, mutta se ei ole ehdottoman välttämätöntä."
},
"noteLicense": {
"pre": "Lähettämällä hyväksyt että kirjasto julkaistaan ",
"link": "MIT-lisenssin ",
"post": "alla, mikä lyhyesti antaa muiden käyttää sitä ilman rajoituksia."
},
"noteItems": "Jokaisella kirjaston kohteella on oltava oma nimensä suodatusta varten. Seuraavat kirjaston kohteet sisältyvät:",
"atleastOneLibItem": "Valitse vähintään yksi kirjaston kohde aloittaaksesi"
},
"publishSuccessDialog": {
"title": "Kirjasto lähetetty",
"content": "Kiitos {{authorName}}. Kirjastosi on lähetetty tarkistettavaksi. Voit seurata sen tilaa",
"link": "täällä"
},
"confirmDialog": {
"resetLibrary": "Tyhjennä kirjasto",
"removeItemsFromLib": "Poista valitut kohteet kirjastosta"
},
"encrypted": {
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä.",
"link": "Blogiartikkeli päästä päähän -salauksesta Excalidraw:ssa"
@@ -345,7 +289,6 @@
"width": "Leveys"
},
"toast": {
"addedToLibrary": "Lisätty kirjastoon",
"copyStyles": "Tyylit kopioitiin.",
"copyToClipboard": "Kopioitiin leikepöydälle.",
"copyToClipboardAsPng": "Kopioitiin {{exportSelection}} leikepöydälle PNG:nä\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Partager",
"showStroke": "Afficher le sélecteur de couleur de trait",
"showBackground": "Afficher le sélecteur de couleur d'arrière-plan",
"toggleTheme": "Changer le thème",
"personalLib": "Bibliothèque personnelle",
"excalidrawLib": "Bibliothèque Excalidraw"
"toggleTheme": "Changer le thème"
},
"buttons": {
"clearReset": "Réinitialiser le canevas",
@@ -137,11 +135,7 @@
"zenMode": "Mode zen",
"exitZenMode": "Quitter le mode zen",
"cancel": "Annuler",
"clear": "Effacer",
"remove": "Supprimer",
"publishLibrary": "Publier",
"submit": "Envoyer",
"confirm": "Confirmer"
"clear": "Effacer"
},
"alerts": {
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Impossible de restaurer la scène depuis ce fichier image",
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
"lineEditor_pointSelected": "Appuyez sur Supprimer pour supprimer le point, Ctrl ou Cmd+D pour le dupliquer, ou faites-le glisser pour le déplacer",
"lineEditor_nothingSelected": "Sélectionnez un point à déplacer ou supprimer, ou maintenez Alt et cliquez pour ajouter de nouveaux points",
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
"publishLibrary": "Publier votre propre bibliothèque"
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement"
},
"canvasError": {
"cannotShowPreview": "Impossible dafficher laperçu",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Effacer la zone de dessin"
},
"publishDialog": {
"title": "Publier la bibliothèque",
"itemName": "Nom de l’élément",
"authorName": "Nom de l'auteur",
"githubUsername": "Nom d'utilisateur GitHub",
"twitterUsername": "Nom d'utilisateur Twitter",
"libraryName": "Nom de la bibliothèque",
"libraryDesc": "Description de la bibliothèque",
"website": "Site web",
"placeholder": {
"authorName": "Votre nom ou nom d'utilisateur",
"libraryName": "Nom de votre bibliothèque",
"libraryDesc": "Description de votre bibliothèque pour aider les gens à comprendre son usage",
"githubHandle": "Nom d'utilisateur GitHub (optionnel), pour que tu puisses modifier la bibliothèque une fois soumise pour vérification",
"twitterHandle": "Nom d'utilisateur Twitter (optionnel), pour savoir qui créditer lors de la promotion sur Twitter",
"website": "Lien vers votre site web personnel ou autre (optionnel)"
},
"errors": {
"required": "Requis",
"website": "Entrer une URL valide"
},
"noteDescription": {
"pre": "Soumets ta bibliothèque pour l'inclure au ",
"link": "dépôt de bibliothèque publique",
"post": "pour permettre son utilisation par autrui dans leurs dessins."
},
"noteGuidelines": {
"pre": "La bibliothèque doit d'abord être approuvée manuellement. Veuillez lire les ",
"link": "lignes directrices",
"post": " avant de la soumettre. Vous aurez besoin d'un compte GitHub pour communiquer et apporter des modifications si demandé, mais ce n'est pas obligatoire."
},
"noteLicense": {
"pre": "En soumettant, vous acceptez que la bibliothèque soit publiée sous la ",
"link": "Licence MIT, ",
"post": "ce qui en gros signifie que tout le monde peut l'utiliser sans restrictions."
},
"noteItems": "Chaque élément de la bibliothèque doit avoir son propre nom afin qu'il soit filtrable. Les éléments de bibliothèque suivants seront inclus :",
"atleastOneLibItem": "Veuillez sélectionner au moins un élément de bibliothèque pour commencer"
},
"publishSuccessDialog": {
"title": "Bibliothèque soumise",
"content": "Merci {{authorName}}. Votre bibliothèque a été soumise pour examen. Vous pouvez suivre le statut",
"link": "ici"
},
"confirmDialog": {
"resetLibrary": "Réinitialiser la bibliothèque",
"removeItemsFromLib": "Enlever les éléments sélectionnés de la bibliothèque"
},
"encrypted": {
"tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais.",
"link": "Article de blog sur le chiffrement de bout en bout dans Excalidraw"
@@ -345,7 +289,6 @@
"width": "Largeur"
},
"toast": {
"addedToLibrary": "Ajouté à la bibliothèque",
"copyStyles": "Styles copiés.",
"copyToClipboard": "Copié dans le presse-papier.",
"copyToClipboardAsPng": "{{exportSelection}} copié dans le presse-papier en PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "שתף",
"showStroke": "הצג צבעי קו מתאר",
"showBackground": "הצג צבעי רקע",
"toggleTheme": "שינוי ערכת העיצוב",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "שינוי ערכת העיצוב"
},
"buttons": {
"clearReset": "אפס את הלוח",
@@ -137,11 +135,7 @@
"zenMode": "מצב זן",
"exitZenMode": "צא ממצב תפריט מרחף",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "לא הצלחנו לשחזר את התצוגה מקובץ התמונה",
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "לחץ לחיצה כפולה או אנטר לעריכת הנקודות",
"lineEditor_pointSelected": "לחץ על Delete להסרת נקודה, CtrlOrCmd+D לשכפל, או גרור להזזה",
"lineEditor_nothingSelected": "בחר נקודה להזזה או הסרה, או החזק את כפתור Alt והקלק להוספת נקודות חדשות",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "לא הצלחנו להציג את התצוגה המקדימה",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם.",
"link": "פוסט בבלוג על הצפנה מקצה לקצב ב-Excalidraw"
@@ -345,7 +289,6 @@
"width": "רוחב"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "העתק סגנונות.",
"copyToClipboard": "הועתק אל הלוח.",
"copyToClipboardAsPng": "{{exportSelection}} הועתקה ללוח כ-PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "कैनवास रीसेट करें",
@@ -137,11 +135,7 @@
"zenMode": "ज़ेन मोड",
"exitZenMode": "जेन मोड से बाहर निकलें",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "इससे पूरा कैनवास साफ हो जाएगा। क्या आपको यकीन है?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "बिंदुओं को संपादित करने के लिए Enter पर डबल-क्लिक करें या दबाएँ",
"lineEditor_pointSelected": "बिंदु हटाने के लिए डिलीट दबाएं, प्रतिरूपित करने के लिए कण्ट्रोल या कमांड डी दबाएं या स्थानांतरित करने के लिए खींचे",
"lineEditor_nothingSelected": "स्थानांतरित करने या हटाने के लिए एक बिंदु का चयन करें, या Alt दबाए रखें और नए बिंदुओं को जोड़ने के लिए क्लिक करें",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "पूर्वावलोकन नहीं दिखा सकते हैं",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।",
"link": ""
@@ -345,7 +289,6 @@
"width": "चौड़ाई"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "काॅपी कीए स्टाइल",
"copyToClipboard": "क्लिपबोर्ड में कॉपी कीए",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "Vászon törlése",
@@ -137,11 +135,7 @@
"zenMode": "Letisztult mód",
"exitZenMode": "Kilépés a letisztult módból",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "Ez a művelet törli a vászont. Biztos benne?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "A jelenet visszaállítása nem sikerült ebből a kép fájlból",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Kattints duplán, vagy nyomj entert a pontok szerkesztéséhez",
"lineEditor_pointSelected": "Nyomd meg a delete gombot a pont eltávolításához, Ctrl vagy Cmd + D-t a duplikáláshoz, vagy húzva mozgasd",
"lineEditor_nothingSelected": "Válassz ki egy pontot a mozgatáshoz vagy törtléshez, vagy az Alt lenyomása mellett kattintva hozz létre új pontokat",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "Előnézet nem jeleníthető meg",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "A rajzaidat végpontok közötti titkosítással tároljuk, tehát az Excalidraw szervereiről se tud más belenézni.",
"link": ""
@@ -345,7 +289,6 @@
"width": "Szélesség"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Bagikan",
"showStroke": "Tampilkan garis pengambil warna",
"showBackground": "Tampilkan latar pengambil warna",
"toggleTheme": "Ubah tema",
"personalLib": "Pustaka Pribadi",
"excalidrawLib": "Pustaka Excalidraw"
"toggleTheme": "Ubah tema"
},
"buttons": {
"clearReset": "Setel Ulang Kanvas",
@@ -137,11 +135,7 @@
"zenMode": "Mode zen",
"exitZenMode": "Keluar dari mode zen",
"cancel": "Batal",
"clear": "Hapus",
"remove": "Hapus",
"publishLibrary": "Terbitkan",
"submit": "Kirimkan",
"confirm": "Konfirmasi"
"clear": "Hapus"
},
"alerts": {
"clearReset": "Ini akan menghapus semua yang ada dikanvas. Apakah kamu yakin ?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Pemandangan tidak dapat dipulihkan dari file gambar ini",
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Klik ganda atau tekan Enter untuk mengedit titik",
"lineEditor_pointSelected": "Tekan Delete untuk menghapus titik, Ctrl/Cmd + D untuk menduplikasi, atau seret untuk memindahkan",
"lineEditor_nothingSelected": "Pilih sebuah titik untuk memindah atau menghapus, atau tekan Alt dan klik untuk menambahkan titik baru",
"placeImage": "Klik untuk tempatkan gambar, atau klik dan jatuhkan untuk tetapkan ukuran secara manual",
"publishLibrary": "Terbitkan pustaka Anda"
"placeImage": "Klik untuk tempatkan gambar, atau klik dan jatuhkan untuk tetapkan ukuran secara manual"
},
"canvasError": {
"cannotShowPreview": "Tidak dapat menampilkan pratinjau",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Hapus kanvas"
},
"publishDialog": {
"title": "Terbitkan pustaka",
"itemName": "Nama item",
"authorName": "Nama pembuat",
"githubUsername": "Nama pengguna github",
"twitterUsername": "Nama pengguna Twitter",
"libraryName": "Nama Pustaka",
"libraryDesc": "Deskripsi pustaka",
"website": "Situs Web",
"placeholder": {
"authorName": "Nama atau nama pengguna Anda",
"libraryName": "Nama dari pustaka Anda",
"libraryDesc": "Deskripsi pustaka Anda untuk membantu orang mengerti penggunaannya",
"githubHandle": "Akun GitHub (opsional), jadi Anda dapat mengubah pustaka ketika diserahkan untuk review",
"twitterHandle": "Nama pengguna Twitter (opsional), jadi kami tahu siapa dipuji ketika mempromosikannya melalui Twitter",
"website": "Hubungkan ke situs personal Anda atau lainnya (opsional)"
},
"errors": {
"required": "Dibutuhkan",
"website": "Masukkan URL valid"
},
"noteDescription": {
"pre": "Kirimkan pustaka Anda untuk disertakan di ",
"link": "repositori pustaka publik",
"post": "untuk orang lain menggunakannya dalam gambar mereka."
},
"noteGuidelines": {
"pre": "Pustaka butuh disetujui secara manual terlebih dahulu. Baca ",
"link": "pedoman",
"post": " sebelum mengirim. Anda butuh akun GitHub untuk berkomunikasi dan membuat perubahan jika dibutuhkan, tetapi tidak wajib dibutukan."
},
"noteLicense": {
"pre": "Dengan mengkirimkannya, Anda setuju pustaka akan diterbitkan dibawah ",
"link": "Lisensi MIT, ",
"post": "yang artinya siapa pun dapat menggunakannya tanpa batasan."
},
"noteItems": "Setiap item pustaka harus memiliki nama, sehingga bisa disortir. Item pustaka di bawah ini akan dimasukan:",
"atleastOneLibItem": "Pilih setidaknya satu item pustaka untuk mulai"
},
"publishSuccessDialog": {
"title": "Pustaka telah dikirm",
"content": "Terima kasih {{authorName}}. pustaka Anda telah diserahkan untuk ditinjau ulang. Anda dapat cek statusnya",
"link": "di sini"
},
"confirmDialog": {
"resetLibrary": "Reset pustaka",
"removeItemsFromLib": "Hapus item yang dipilih dari pustaka"
},
"encrypted": {
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya.",
"link": "Pos blog tentang enkripsi ujung ke ujung di Excalidraw"
@@ -345,7 +289,6 @@
"width": "Lebar"
},
"toast": {
"addedToLibrary": "Tambahkan ke pustaka",
"copyStyles": "Gaya tersalin.",
"copyToClipboard": "Tersalin ke papan klip.",
"copyToClipboardAsPng": "Tersalin {{exportSelection}} ke clipboard sebagai PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Condividi",
"showStroke": "Mostra selettore colore del tratto",
"showBackground": "Mostra selettore colore di sfondo",
"toggleTheme": "Cambia tema",
"personalLib": "Libreria Personale",
"excalidrawLib": "Libreria di Excalidraw"
"toggleTheme": "Cambia tema"
},
"buttons": {
"clearReset": "Svuota la tela",
@@ -137,11 +135,7 @@
"zenMode": "Modalità Zen",
"exitZenMode": "Uscire dalla modalità zen",
"cancel": "Annulla",
"clear": "Cancella",
"remove": "Rimuovi",
"publishLibrary": "Pubblica",
"submit": "Invia",
"confirm": "Conferma"
"clear": "Cancella"
},
"alerts": {
"clearReset": "Questa azione cancellerà l'intera tela. Sei sicuro?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Impossibile ripristinare la scena da questo file immagine",
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.",
"resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?",
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Fai doppio click o premi invio per modificare i punti",
"lineEditor_pointSelected": "Premere Elimina per rimuovere il punto, CtrlOrCmd+D per duplicare o trascinare per spostare",
"lineEditor_nothingSelected": "Seleziona un punto per spostare o rimuovere, oppure tieni premuto Alt e fai clic per aggiungere nuovi punti",
"placeImage": "Fai click per posizionare l'immagine, o click e trascina per impostarne la dimensione manualmente",
"publishLibrary": "Pubblica la tua libreria"
"placeImage": "Fai click per posizionare l'immagine, o click e trascina per impostarne la dimensione manualmente"
},
"canvasError": {
"cannotShowPreview": "Impossibile visualizzare l'anteprima",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Svuota la tela"
},
"publishDialog": {
"title": "Pubblica la libreria",
"itemName": "Nome dell'elemento",
"authorName": "Nome dell'autore",
"githubUsername": "Nome utente di GitHub",
"twitterUsername": "Nome utente di Twitter",
"libraryName": "Nome della libreria",
"libraryDesc": "Descrizione della libreria",
"website": "Sito Web",
"placeholder": {
"authorName": "Il tuo nome o nome utente",
"libraryName": "Nome della tua libreria",
"libraryDesc": "Descrizione della tua libreria per aiutare le persone a comprenderne lo scopo",
"githubHandle": "Handle di GitHub (opzionale), così che tu possa modificare la libreria una volta inviata per la revisione",
"twitterHandle": "Nome utente di Twitter (opzionale), così che sappiamo chi accreditare promuovendo su Twitter",
"website": "Link al tuo sito web personale o altro (opzionale)"
},
"errors": {
"required": "Obbligatorio",
"website": "Inserisci un URL valido"
},
"noteDescription": {
"pre": "Invia la tua libreria da includere nella ",
"link": "repository della libreria pubblica",
"post": "perché sia usata da altri nei loro disegni."
},
"noteGuidelines": {
"pre": "La libreria dev'esser prima approvata manualmente. Sei pregato di leggere le ",
"link": "linee guida",
"post": " prima di inviarla. Necessiterai di un profilo di GitHub per comunicare ed effettuare modifiche se richiesto, ma non è strettamente necessario."
},
"noteLicense": {
"pre": "Inviando, acconsenti che la libreria sarà pubblicata sotto la ",
"link": "Licenza MIT, ",
"post": "che in breve significa che chiunque possa usarla senza restrizioni."
},
"noteItems": "Ogni elemento della libreria deve avere il proprio nome, così che sia filtrabile. Gli elementi della seguente libreria saranno inclusi:",
"atleastOneLibItem": "Sei pregato di selezionare almeno un elemento della libreria per iniziare"
},
"publishSuccessDialog": {
"title": "Libreria inviata",
"content": "Grazie {{authorName}}. La tua libreria è stata inviata per la revisione. Puoi monitorarne lo stato",
"link": "qui"
},
"confirmDialog": {
"resetLibrary": "Ripristina la libreria",
"removeItemsFromLib": "Rimuovi gli elementi selezionati dalla libreria"
},
"encrypted": {
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere.",
"link": "Articolo del blog sulla crittografia end-to-end di Excalidraw"
@@ -345,7 +289,6 @@
"width": "Larghezza"
},
"toast": {
"addedToLibrary": "Aggiunto alla libreria",
"copyStyles": "Stili copiati.",
"copyToClipboard": "Copiato negli appunti.",
"copyToClipboardAsPng": "{{exportSelection}} copiato negli appunti come PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "共有",
"showStroke": "ストロークカラーピッカーを表示",
"showBackground": "背景色ピッカーを表示",
"toggleTheme": "テーマの切り替え",
"personalLib": "個人ライブラリ",
"excalidrawLib": "Excalidrawライブラリ"
"toggleTheme": "テーマの切り替え"
},
"buttons": {
"clearReset": "キャンバスのリセット",
@@ -137,11 +135,7 @@
"zenMode": "Zenモード",
"exitZenMode": "集中モードをやめる",
"cancel": "キャンセル",
"clear": "消去",
"remove": "削除",
"publishLibrary": "公開",
"submit": "送信",
"confirm": "確認"
"clear": "消去"
},
"alerts": {
"clearReset": "この操作によってキャンバス全体が消えます。よろしいですか?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "このイメージファイルからシーンを復元できませんでした",
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "ポイントを編集するには、ダブルクリックまたはEnterキーを押します",
"lineEditor_pointSelected": "削除ボタンを押して点を削除します。Ctrl+D または Cmd+D で複製します。またはドラッグして移動します",
"lineEditor_nothingSelected": "移動または削除する点を選択するか、Altキーを押しながらクリックして新しい点を追加します",
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します",
"publishLibrary": "自分のライブラリを公開"
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します"
},
"canvasError": {
"cannotShowPreview": "プレビューを表示できません",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "キャンバスを消去"
},
"publishDialog": {
"title": "ライブラリを公開",
"itemName": "アイテム名",
"authorName": "作成者名",
"githubUsername": "GitHub ユーザ名",
"twitterUsername": "Twitter ユーザ名",
"libraryName": "ライブラリ名",
"libraryDesc": "ライブラリの説明",
"website": "Webサイト",
"placeholder": {
"authorName": "お名前またはユーザー名",
"libraryName": "あなたのライブラリ名",
"libraryDesc": "ライブラリの使い方を理解するための説明",
"githubHandle": "GitHubハンドル(任意)。一度レビューのために送信されると、ライブラリを編集できます",
"twitterHandle": "Twitterのユーザー名 (任意)。Twitterでプロモーションする際にクレジットする人を知っておくためのものです",
"website": "個人のウェブサイトまたは他のサイトへのリンク (オプション)"
},
"errors": {
"required": "必須項目",
"website": "有効な URL を入力してください"
},
"noteDescription": {
"pre": "以下に含めるライブラリを提出してください ",
"link": "公開ライブラリのリポジトリ",
"post": "他の人が作図に使えるようにするためです"
},
"noteGuidelines": {
"pre": "最初にライブラリを手動で承認する必要があります。次をお読みください ",
"link": "ガイドライン",
"post": " 送信する前に、GitHubアカウントが必要になりますが、必須ではありません。"
},
"noteLicense": {
"pre": "提出することにより、ライブラリが次の下で公開されることに同意します: ",
"link": "MIT ライセンス",
"post": "つまり誰でも制限なく使えるということです"
},
"noteItems": "",
"atleastOneLibItem": "開始するには少なくとも1つのライブラリ項目を選択してください"
},
"publishSuccessDialog": {
"title": "ライブラリを送信しました",
"content": "{{authorName}} さん、ありがとうございます。あなたのライブラリはレビューのために提出されました。状況を追跡できます。",
"link": "こちら"
},
"confirmDialog": {
"resetLibrary": "ライブラリをリセット",
"removeItemsFromLib": "選択したアイテムをライブラリから削除"
},
"encrypted": {
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。",
"link": "Excalidrawのエンドツーエンド暗号化に関するブログ記事"
@@ -345,7 +289,6 @@
"width": "幅"
},
"toast": {
"addedToLibrary": "ライブラリに追加しました",
"copyStyles": "スタイルをコピーしました。",
"copyToClipboard": "クリップボードにコピー",
"copyToClipboardAsPng": "{{exportSelection}} を PNG 形式でクリップボードにコピーしました\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Bḍu",
"showStroke": "Beqqeḍ amelqaḍ n yini n yizirig",
"showBackground": "Beqqeḍ amelqaḍ n yini n ugilal",
"toggleTheme": "Snifel asentel",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Snifel asentel"
},
"buttons": {
"clearReset": "Ales awennez n teɣzut n usuneɣ",
@@ -137,11 +135,7 @@
"zenMode": "Askar Zen",
"exitZenMode": "Ffeɣ seg uskar Zen",
"cancel": "Sefsex",
"clear": "Sfeḍ",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": "Sfeḍ"
},
"alerts": {
"clearReset": "Ayagi ad isfeḍ akk taɣzut n usuneɣ. Tetḥeqqeḍ?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Asayes ulamek ara d-yettwarr seg ufaylu-agi n tugna",
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Ssit snat n tikkal neɣ ssed taqeffalt Kcem akken ad tẓergeḍ tinqiḍin",
"lineEditor_pointSelected": "Ssed taqeffalt kkes akken ad tekkseḍ tanqiḍt, CtrlOrCmd+D akken ad tsiselgeḍ, neɣ zuɣer akken ad tesmuttiḍ",
"lineEditor_nothingSelected": "Fren tanqiḍt ara tesmuttiḍ neɣ ara tekkseḍ, neɣ ṭṭef taqeffalt Alt akken ad ternuḍ tinqiḍin timaynutin",
"placeImage": "Ssit akken ad tserseḍ tugna, neɣ ssit u zuɣer akken ad tesbaduḍ tiddi-ines s ufus",
"publishLibrary": ""
"placeImage": "Ssit akken ad tserseḍ tugna, neɣ ssit u zuɣer akken ad tesbaduḍ tiddi-ines s ufus"
},
"canvasError": {
"cannotShowPreview": "Ulamek abeqqeḍ n teskant",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. ",
"link": "Amagrad ɣef uwgelhen ixef s ixef di Excalidraw"
@@ -345,7 +289,6 @@
"width": "Tehri"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Iɣunab yettwaneɣlen.",
"copyToClipboard": "Yettwaɣel ɣer tecfawit.",
"copyToClipboardAsPng": "{{exportSelection}} yettwanɣel ɣer tecfawit am PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "",
@@ -137,11 +135,7 @@
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Сіздің сызбаларыңыз өтпелі шифрлеу арқылы шифрланған, сондықтан Excalidraw серверлері оларды ешқашан көрмейді.",
"link": "Excalidraw қолданатын өтпелі шифрлеу туралы блог жазбасы"
@@ -345,7 +289,6 @@
"width": "Ені"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Стильдер көшірілді.",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "캔버스 초기화",
@@ -137,11 +135,7 @@
"zenMode": "젠 모드",
"exitZenMode": "젠 모드 종료하기",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "모든 작업 내용이 초기화됩니다. 계속하시겠습니까?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "이미지 파일에서 화면을 복구할 수 없었습니다",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "지점을 수정하려면 두 번 클릭하거나 Enter 키를 누르세요.",
"lineEditor_pointSelected": "제거하려면 Delete 키, 복제하려면 CtrlOrCmd+D, 이동하려면 드래그하세요.",
"lineEditor_nothingSelected": "옮기거나 지울 지점을 선택하거나, Alt를 누른 상태로 클릭해 새 지점을 만드세요",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "미리보기를 볼 수 없습니다",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "그림은 종단 간 암호화되므로 Excalidraw의 서버는 절대로 내용을 알 수 없습니다.",
"link": ""
@@ -345,7 +289,6 @@
"width": "너비"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "스타일 복사.",
"copyToClipboard": "클립보드로 복사.",
"copyToClipboardAsPng": "",
+4 -61
View File
@@ -43,7 +43,7 @@
"exportEmbedScene": "Iegult ainu",
"exportEmbedScene_details": "Ainas dati tiks iekļauti saglabātajā PNG/SVG datnē, lai no tās būtu iespējams ainu atgūt. Tas palielinās datnes izmēru.",
"addWatermark": "Pievienot \"Radīts ar Excalidraw\"",
"handDrawn": "Rokraksts",
"handDrawn": "Ar roku zīmēts",
"normal": "Parasts",
"code": "Kods",
"small": "Mazs",
@@ -100,9 +100,7 @@
"share": "Kopīgot",
"showStroke": "Rādīt svītras krāsas atlasītāju",
"showBackground": "Rādīt fona krāsas atlasītāju",
"toggleTheme": "Pārslēgt krāsu tēmu",
"personalLib": "Personīgā bibliotēka",
"excalidrawLib": "Excalidraw bibliotēka"
"toggleTheme": "Pārslēgt krāsu tēmu"
},
"buttons": {
"clearReset": "Atiestatīt tāfeli",
@@ -137,11 +135,7 @@
"zenMode": "Zen režīms",
"exitZenMode": "Pamest Zen režīmu",
"cancel": "Atcelt",
"clear": "Notīrīt",
"remove": "Noņemt",
"publishLibrary": "Publicēt",
"submit": "Iesniegt",
"confirm": "Apstiprināt"
"clear": "Notīrīt"
},
"alerts": {
"clearReset": "Šī funkcija notīrīs visu tāfeli. Vai turpināt?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Ainu nevarēja atgūt no attēla faila",
"invalidSceneUrl": "Nevarēja importēt ainu no norādītā URL. Vai nu tas ir nederīgs, vai nesatur derīgus Excalidraw JSON datus.",
"resetLibrary": "Šī funkcija iztukšos bibliotēku. Vai turpināt?",
"removeItemsFromsLibrary": "Vai izņemt {{count}} vienumu(s) no bibliotēkas?",
"invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Dubultklikšķiniet vai spiediet ievades taustiņu, lai rediģētu punktus",
"lineEditor_pointSelected": "Spiediet dzēšanas taustiņu, lai noņemtu punktu, CtrlOrCmd+D, lai to kopētu, vai velciet, lai pārvietotu",
"lineEditor_nothingSelected": "Atlasiet punktu, lai to pārvietotu vai noņemtu; lai pievienotu jaunus punktus, turiet nospiestu Alt taustiņu",
"placeImage": "Klikšķiniet, lai novietotu attēlu, vai spiediet un velciet, lai iestatītu tā izmēru",
"publishLibrary": "Publicēt savu bibliotēku"
"placeImage": "Klikšķiniet, lai novietotu attēlu, vai spiediet un velciet, lai iestatītu tā izmēru"
},
"canvasError": {
"cannotShowPreview": "Nevar rādīt priekšskatījumu",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Notīrīt tāfeli"
},
"publishDialog": {
"title": "Publicēt bibliotēku",
"itemName": "Vienuma nosaukums",
"authorName": "Autora vārds",
"githubUsername": "GitHub lietotājvārds",
"twitterUsername": "Twitter lietotājvārds",
"libraryName": "Bibliotēkas nosaukums",
"libraryDesc": "Bibliotēkas apraksts",
"website": "Mājaslapa",
"placeholder": {
"authorName": "Jūsu vārds vai lietotājvārds",
"libraryName": "Jūsu bibliotēkas nosaukums",
"libraryDesc": "Bibliotēkas apraksts, kas palīdzēs citiem saprast tās pielietojumu",
"githubHandle": "GitHub lietotājvārds (neobligāts), lai jūs varētu rediģēt bibliotēku pēc tās iesniegšanas izskatīšanai",
"twitterHandle": "Twitter lietotājvārds (neobligāts), lai mēs varētu jūs pieminēt kā autoru, kad reklamēsim bibliotēku platformā Twitter",
"website": "Saikne uz jūsu personīgo mājaslapu vai kādu citu lapu (neobligāta)"
},
"errors": {
"required": "Obligāts",
"website": "Ievadiet derīgu URL"
},
"noteDescription": {
"pre": "Iesniegt savu bibliotēku iekļaušanai ",
"link": "publiskajā bibliotēku datubāzē",
"post": ", lai citi to varētu izmantot savos zīmējumos."
},
"noteGuidelines": {
"pre": "Šai bibliotēkai vispirms jātiek manuāli apstiprinātai. Lūdzu, izlasiet ",
"link": "norādījumus",
"post": " pirms iesniegšanas. Jums vajadzēs GitHub kontu, lai sazinātos un veiktu izmaiņas, ja tādas būs pieprasītas, bet tas nav absolūti nepieciešams."
},
"noteLicense": {
"pre": "Iesniedzot bibliotēku, jūs piekrītat tās publicēšanai saskaņā ar ",
"link": "MIT Licenci, ",
"post": "kas īsumā nozīmē, ka jebkurš to varēs izmantot bez ierobežojumiem."
},
"noteItems": "Katram bibliotēkas vienumam jābūt savam nosaukumam, lai to varētu atrast filtrējot. Tiks iekļauti sekojošie bibliotēkas vienumi:",
"atleastOneLibItem": "Lūdzu, atlasiet vismaz vienu bibliotēkas vienumu, lai sāktu darbu"
},
"publishSuccessDialog": {
"title": "Bibliotēka iesniegta",
"content": "Paldies, {{authorName}}! Jūsu bibliotēka iesniegta izskatīšanai. Jūs varat izsekot iesnieguma statusam",
"link": "šeit"
},
"confirmDialog": {
"resetLibrary": "Atiestatīt bibliotēku",
"removeItemsFromLib": "Noņemt atlasītos vienumus no bibliotēkas"
},
"encrypted": {
"tooltip": "Jūsu zīmējumi ir šifrēti no gala līdz galam; līdz ar to Excalidraw serveri tos nekad neredzēs.",
"link": "Ieraksts par šifrēšanu no gala līdz galam Excalidraw blogā"
@@ -345,7 +289,6 @@
"width": "Platums"
},
"toast": {
"addedToLibrary": "Pievienots bibliotēkai",
"copyStyles": "Nokopēja stilus.",
"copyToClipboard": "Nokopēja starpliktuvē.",
"copyToClipboardAsPng": "Nokopēja {{exportSelection}} starpliktuvē kā PNG ({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": ""
},
"buttons": {
"clearReset": "ကားချပ်ရှင်းလင်း",
@@ -137,11 +135,7 @@
"zenMode": "",
"exitZenMode": "ဇင်မြင်ကွင်းမှထွက်",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": ""
},
"alerts": {
"clearReset": "ကားချပ်တစ်ခုလုံးရှင်းလင်းပါတော့မည်။ အတည်ပြုပါ။",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "ဤပုံဖြင့်မြင်ကွင်းပြန်လည်မရယူနိုင်ပါ။",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": ""
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "အမှတ်များပြင်ဆင်သတ်မှတ်ရင် ကလစ်နှစ်ချက် (သို့) Enter ကိုနှိပ်ပါ",
"lineEditor_pointSelected": "အမှတ်များအား ဖျက်ရန် Delete နှင့် ပွားရန် Ctrl/Cmd + D သုံးပါ၊ ရွှေ့လိုပါက တရွတ်ဆွဲပါ",
"lineEditor_nothingSelected": "ရွှေ့လို (သို့) ဖယ်ရှားလိုသောအမှတ်ကိုရွေးပါ၊ Alt နှင့် ကလစ်တွဲနှိပ်၍လည်းအမှတ်အသစ်ထပ်ထည့်နိုင်သည်",
"placeImage": "",
"publishLibrary": ""
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "နမူနာမပြသနိုင်ပါ",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "ရေးဆွဲထားသောပုံများအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်ထားသဖြင့် Excalidraw ၏ဆာဗာများပင်လျှင်မြင်တွေ့ရမည်မဟုတ်ပါ။",
"link": ""
@@ -345,7 +289,6 @@
"width": "အကျယ်"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Del",
"showStroke": "Vis fargevelger for kantfarge",
"showBackground": "Vis fargevelger for bakgrunnsfarge",
"toggleTheme": "Veksle tema",
"personalLib": "Personlig bibliotek",
"excalidrawLib": "Excalidraw-bibliotek"
"toggleTheme": "Veksle tema"
},
"buttons": {
"clearReset": "Tøm lerretet og tilbakestill bakgrunnsfargen",
@@ -137,11 +135,7 @@
"zenMode": "Zen-modus",
"exitZenMode": "Avslutt zen-modus",
"cancel": "Avbryt",
"clear": "Tøm",
"remove": "Fjern",
"publishLibrary": "Publiser",
"submit": "Send inn",
"confirm": "Bekreft"
"clear": "Tøm"
},
"alerts": {
"clearReset": "Dette vil tømme lerretet. Er du sikker?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Scenen kunne ikke gjenopprettes fra denne bildefilen",
"invalidSceneUrl": "Kunne ikke importere scene fra den oppgitte URL-en. Den er enten ødelagt, eller inneholder ikke gyldig Excalidraw JSON-data.",
"resetLibrary": "Dette vil tømme biblioteket ditt. Er du sikker?",
"removeItemsFromsLibrary": "Slett {{count}} element(er) fra biblioteket?",
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Dobbeltklikk eller trykk Enter for å redigere punkter",
"lineEditor_pointSelected": "Trykk på Slett for å fjerne punktet, Ctrl / Cmd+D for å duplisere, eller dra for å flytte",
"lineEditor_nothingSelected": "Velg et punkt å flytte eller fjerne, eller hold Alt og klikk for å legge til nye punkter",
"placeImage": "Klikk for å plassere bildet, eller klikk og dra for å angi størrelsen manuelt",
"publishLibrary": "Publiser ditt eget bibliotek"
"placeImage": "Klikk for å plassere bildet, eller klikk og dra for å angi størrelsen manuelt"
},
"canvasError": {
"cannotShowPreview": "Kan ikke vise forhåndsvisning",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": "Tøm lerret"
},
"publishDialog": {
"title": "Publiser bibliotek",
"itemName": "Elementnavn",
"authorName": "Forfatterens navn",
"githubUsername": "GitHub-brukernavnet",
"twitterUsername": "Twitter-brukernavn",
"libraryName": "Biblioteknavn",
"libraryDesc": "Beskrivelse av bibliotek",
"website": "Nettsted",
"placeholder": {
"authorName": "Ditt navn eller brukernavn",
"libraryName": "Navnet på biblioteket ditt",
"libraryDesc": "Beskrivelse av biblioteket ditt for å hjelpe folk med å forstå bruken",
"githubHandle": "Github-brukernavn (valgfritt), slik at du kan redigere biblioteket når du har sendt inn for gjennomgang",
"twitterHandle": "Twitter-brukernavn (valgfritt), slik at vi vet hvem vi skal kreditere når promotert på Twitter",
"website": "Lenke til din personlige nettside eller et annet sted (valgfritt)"
},
"errors": {
"required": "Påkrevd",
"website": "Angi en gyldig nettadresse"
},
"noteDescription": {
"pre": "Send inn biblioteket ditt som skal inkluderes i ",
"link": "kildekode for offentlig bibliotek",
"post": "for andre å bruke dem i tegninger."
},
"noteGuidelines": {
"pre": "Biblioteket må godkjennes manuelt først. Les ",
"link": "retningslinjene",
"post": " før innsending. Du vil trenge en GitHub-konto for å kommunisere og gjøre endringer hvis ønsket, men det er ikke påkrevd."
},
"noteLicense": {
"pre": "Ved å sende inn godtar du at biblioteket blir publisert under ",
"link": "MIT-lisens, ",
"post": "som kortfattet betyr at andre kan bruke dem uten begrensninger."
},
"noteItems": "Hvert bibliotek må ha sitt eget navn, så det er filtrerbart. Følgende bibliotekselementer vil bli inkludert:",
"atleastOneLibItem": "Vennligst velg minst ett bibliotek for å komme i gang"
},
"publishSuccessDialog": {
"title": "Bibliotek innsendt",
"content": "Takk {{authorName}}. Ditt bibliotek har blitt sendt inn for gjennomgang. Du kan spore statusen",
"link": "her"
},
"confirmDialog": {
"resetLibrary": "Nullstill bibliotek",
"removeItemsFromLib": "Fjern valgte elementer fra bibliotek"
},
"encrypted": {
"tooltip": "Dine tegninger er ende-til-ende-krypterte slik at Excalidraw sine servere aldri vil se dem.",
"link": "Blogginnlegg om ende-til-ende-kryptering i Excalidraw"
@@ -345,7 +289,6 @@
"width": "Bredde"
},
"toast": {
"addedToLibrary": "Lagt til i biblioteket",
"copyStyles": "Kopierte stiler.",
"copyToClipboard": "Kopiert til utklippstavlen.",
"copyToClipboardAsPng": "Kopierte {{exportSelection}} til utklippstavlen som PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Deel",
"showStroke": "Toon lijn kleur kiezer",
"showBackground": "Toon achtergrondkleur kiezer",
"toggleTheme": "Thema aan/uit",
"personalLib": "Persoonlijke bibliotheek",
"excalidrawLib": "Excalidraw bibliotheek"
"toggleTheme": "Thema aan/uit"
},
"buttons": {
"clearReset": "Canvas opnieuw instellen",
@@ -137,11 +135,7 @@
"zenMode": "Zen modus",
"exitZenMode": "Verlaat zen modus",
"cancel": "Annuleren",
"clear": "Wissen",
"remove": "Verwijderen",
"publishLibrary": "Publiceren",
"submit": "Versturen",
"confirm": "Bevestigen"
"clear": "Wissen"
},
"alerts": {
"clearReset": "Dit zal het hele canvas verwijderen. Weet je het zeker?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Scène kan niet worden hersteld vanuit dit afbeeldingsbestand",
"invalidSceneUrl": "Kan scène niet importeren vanuit de opgegeven URL. Het is onjuist of bevat geen geldige Excalidraw JSON-gegevens.",
"resetLibrary": "Dit zal je bibliotheek wissen. Weet je het zeker?",
"removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?",
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Dubbelklik of druk op Enter om punten te bewerken",
"lineEditor_pointSelected": "Druk op Delete om een punt te verwijderen, op CtrlOrCmd+D om te kopiëren, of sleeg om te verplaatsen",
"lineEditor_nothingSelected": "Selecteer een punt om te verplaatsen of te verwijderen, of houd Alt ingedrukt en klik om nieuwe punten toe te voegen",
"placeImage": "",
"publishLibrary": "Publiceer je eigen bibliotheek"
"placeImage": ""
},
"canvasError": {
"cannotShowPreview": "Kan voorbeeld niet tonen",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "Publiceer bibliotheek",
"itemName": "Itemnaam",
"authorName": "Naam auteur",
"githubUsername": "GitHub gebruikersnaam",
"twitterUsername": "Twitter gebruikersnaam",
"libraryName": "Bibliotheek naam",
"libraryDesc": "Bibliotheek beschrijving",
"website": "Website",
"placeholder": {
"authorName": "Uw naam of gebruikersnaam:",
"libraryName": "Naam van je bibliotheek",
"libraryDesc": "Beschrijving van uw bibliotheek om mensen te helpen het gebruik ervan te begrijpen",
"githubHandle": "",
"twitterHandle": "",
"website": "Link naar je persoonlijke website of elders (optioneel)"
},
"errors": {
"required": "Vereist",
"website": "Vul een geldige URL in"
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": "Hier"
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Je tekeningen zijn beveiligd met end-to-end encryptie, dus Excalidraw's servers zullen nooit zien wat je tekent.",
"link": "Blog post over end-to-end versleuteling in Excalidraw"
@@ -345,7 +289,6 @@
"width": "Breedte"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Stijlen gekopieerd.",
"copyToClipboard": "Gekopieerd naar het klembord.",
"copyToClipboardAsPng": "{{exportSelection}} naar klembord gekopieerd als PNG\n({{exportColorScheme}})",
+3 -60
View File
@@ -100,9 +100,7 @@
"share": "Del",
"showStroke": "Vis fargeveljar for linjer",
"showBackground": "Vis fargeveljar for bakgrunn",
"toggleTheme": "Veksle tema",
"personalLib": "",
"excalidrawLib": ""
"toggleTheme": "Veksle tema"
},
"buttons": {
"clearReset": "Tilbakestill lerretet",
@@ -137,11 +135,7 @@
"zenMode": "Zen-modus",
"exitZenMode": "Avslutt zen-modus",
"cancel": "Avbryt",
"clear": "Tøm",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
"clear": "Tøm"
},
"alerts": {
"clearReset": "Dette vil tømme lerretet. Er du sikker?",
@@ -163,7 +157,6 @@
"cannotRestoreFromImage": "Scena kunne ikkje gjenopprettast frå denne biletfila",
"invalidSceneUrl": "Kunne ikkje hente noko scene frå den URL-en. Ho er anten øydelagd eller inneheld ikkje gyldig Excalidraw JSON-data.",
"resetLibrary": "Dette vil fjerne alt innhald frå biblioteket. Er du sikker?",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "Krypteringsnøkkelen må ha 22 teikn. Sanntidssamarbeid er deaktivert."
},
"errors": {
@@ -206,8 +199,7 @@
"lineEditor_info": "Dobbeltklikk eller trykk Enter for å redigere punkt",
"lineEditor_pointSelected": "Trykk på Slett for å fjerne punktet, CtrlOrCmd+D for å duplisere, eller dra for å flytte",
"lineEditor_nothingSelected": "Vel eit punkt å flytte eller fjerne, eller hald Alt og klikk for å legge til nye punkt",
"placeImage": "Klikk for å plassere biletet, eller klikk og drag for å velje storleik manuelt",
"publishLibrary": ""
"placeImage": "Klikk for å plassere biletet, eller klikk og drag for å velje storleik manuelt"
},
"canvasError": {
"cannotShowPreview": "Kan ikkje vise førehandsvising",
@@ -277,54 +269,6 @@
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": {
"pre": "",
"link": "",
"post": ""
},
"noteGuidelines": {
"pre": "",
"link": "",
"post": ""
},
"noteLicense": {
"pre": "",
"link": "",
"post": ""
},
"noteItems": "",
"atleastOneLibItem": ""
},
"publishSuccessDialog": {
"title": "",
"content": "",
"link": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"encrypted": {
"tooltip": "Teikningane dine er ende-til-ende-krypterte slik at Excalidraw sine serverar aldri får sjå dei.",
"link": "Blogginnlegg om ende-til-ende-kryptering i Excalidraw"
@@ -345,7 +289,6 @@
"width": "Breidde"
},
"toast": {
"addedToLibrary": "",
"copyStyles": "Kopierte stilane.",
"copyToClipboard": "Kopiert til utklippstavla.",
"copyToClipboardAsPng": "Kopierte {{exportSelection}} til utklippstavla som PNG\n({{exportColorScheme}})",

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