Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c83a322b6 | |||
| 8b5657e1ce | |||
| 8b2b03347c | |||
| c2a8712593 | |||
| ff1d7728a0 | |||
| 98b5c37e45 | |||
| 7db63bd397 | |||
| 390da3fd0f | |||
| 104664cb9e | |||
| c822055ec8 | |||
| e15d73d94c | |||
| 80ee097b85 | |||
| 10048b877b | |||
| 5dd5862bb9 | |||
| 79989fedda | |||
| cecabc2196 | |||
| ed8fb40b63 | |||
| 6e391728fe | |||
| dfbfbc3f11 | |||
| 9b8ee3cacf | |||
| 4ea73d5d5b | |||
| 618f204ddd | |||
| 720588130c | |||
| f354788cd0 | |||
| 1c7ee09010 | |||
| ca15b0a008 | |||
| 650930c5ce | |||
| 79c0d59244 | |||
| cd50b5f7e9 | |||
| c0434957ff | |||
| 66aeaeb38d | |||
| 7f545e74ab | |||
| a776955579 | |||
| afa7932c9b | |||
| 1ee8d7d082 | |||
| 06db702b5d | |||
| b53d1f6f3e | |||
| ca1f3aa094 | |||
| 8ff159e76e | |||
| f9d2d537a2 | |||
| dac970c640 | |||
| 78bb3b3d84 | |||
| 7d9d7ad297 | |||
| de20a5e3ba | |||
| 289f72e45d | |||
| 6dd0e6a4c5 | |||
| 96b31ecbce | |||
| a132f154cb | |||
| 23acd8f6d1 | |||
| a60709f5ea | |||
| 896c476716 | |||
| 133ba19919 | |||
| a2136bfe9d | |||
| 6fbd64fdaa | |||
| cc4b0c2932 |
@@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
yarn lint-staged
|
||||
|
||||
+11
-10
@@ -21,15 +21,15 @@
|
||||
"dependencies": {
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.15.0",
|
||||
"@testing-library/jest-dom": "5.15.1",
|
||||
"@testing-library/react": "12.1.2",
|
||||
"@tldraw/vec": "0.1.3",
|
||||
"@types/jest": "27.0.2",
|
||||
"@tldraw/vec": "1.1.5",
|
||||
"@types/jest": "27.0.3",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.34",
|
||||
"@types/react": "17.0.37",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.21.1",
|
||||
"browser-fs-access": "0.23.0",
|
||||
"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.0",
|
||||
"sass": "1.43.4",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.43.5",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.5.2"
|
||||
},
|
||||
@@ -62,14 +62,15 @@
|
||||
"@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.22.0",
|
||||
"firebase-tools": "9.23.0",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.0.1",
|
||||
"lint-staged": "12.1.2",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.4.1",
|
||||
"prettier": "2.5.0",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -8,7 +8,12 @@ import { t } from "../i18n";
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState, _, app) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
@@ -25,10 +30,7 @@ export const actionAddToLibrary = register({
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
elements: getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
).map(deepCopyElement),
|
||||
elements: selectedElements.map(deepCopyElement),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { 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 { getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
@@ -34,9 +34,11 @@ const alignSelectedElements = (
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
|
||||
@@ -160,7 +160,7 @@ export const actionResetZoom = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")}>
|
||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="reset-zoom-button"
|
||||
|
||||
@@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
@@ -81,6 +82,7 @@ export const actionCopyAsPng = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
|
||||
@@ -11,6 +11,7 @@ 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[],
|
||||
@@ -21,6 +22,12 @@ 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: {
|
||||
@@ -55,7 +62,7 @@ export const actionDeleteSelected = register({
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
activePointIndex,
|
||||
selectedPointsIndices,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
@@ -65,8 +72,7 @@ export const actionDeleteSelected = register({
|
||||
}
|
||||
if (
|
||||
// case: no point selected → delete whole element
|
||||
activePointIndex == null ||
|
||||
activePointIndex === -1 ||
|
||||
selectedPointsIndices == null ||
|
||||
// case: deleting last remaining point
|
||||
element.points.length < 2
|
||||
) {
|
||||
@@ -86,15 +92,17 @@ 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:
|
||||
activePointIndex === 0 ? null : startBindingElement,
|
||||
endBindingElement:
|
||||
activePointIndex === element.points.length - 1
|
||||
? null
|
||||
: endBindingElement,
|
||||
startBindingElement: selectedPointsIndices?.includes(0)
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
@@ -103,13 +111,15 @@ export const actionDeleteSelected = register({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
...binding,
|
||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
? [selectedPointsIndices[0] - 1]
|
||||
: [0],
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { 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 { getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
@@ -30,9 +30,11 @@ const distributeSelectedElements = (
|
||||
|
||||
const updatedElements = distributeElements(selectedElements, distribution);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
|
||||
@@ -2,13 +2,12 @@ import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { clone } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
@@ -18,41 +17,23 @@ 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 point if selected while editing multi-point element
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element || activePointIndex === null) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
|
||||
if (!ret) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -106,9 +87,12 @@ const duplicateElements = (
|
||||
const finalElements: ExcalidrawElement[] = [];
|
||||
|
||||
let index = 0;
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(elements, appState, true),
|
||||
);
|
||||
while (index < elements.length) {
|
||||
const element = elements[index];
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
if (selectedElementIds.get(element.id)) {
|
||||
if (element.groupIds.length) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
// if group selected, duplicate it atomically
|
||||
@@ -130,7 +114,11 @@ const duplicateElements = (
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
bindTextToShapeAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||
|
||||
return {
|
||||
@@ -140,7 +128,9 @@ const duplicateElements = (
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any),
|
||||
},
|
||||
|
||||
@@ -14,10 +14,60 @@ 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 }) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@@ -162,6 +212,7 @@ 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),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
@@ -9,6 +9,7 @@ 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[],
|
||||
@@ -83,9 +84,11 @@ const flipSelectedElements = (
|
||||
flipDirection,
|
||||
);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
const flipElements = (
|
||||
@@ -142,10 +145,9 @@ const flipElement = (
|
||||
}
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
for (let i = 1; i < element.points.length; i++) {
|
||||
LinearElementEditor.movePoint(element, i, [
|
||||
-element.points[i][0],
|
||||
element.points[i][1],
|
||||
for (let index = 1; index < element.points.length; index++) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||
]);
|
||||
}
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@@ -44,6 +44,7 @@ const enableActionGroup = (
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
@@ -56,6 +57,7 @@ export const actionGroup = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
@@ -83,8 +85,9 @@ export const actionGroup = register({
|
||||
}
|
||||
}
|
||||
const newGroupId = randomId();
|
||||
const selectElementIds = arrayToMap(selectedElements);
|
||||
const updatedElements = elements.map((element) => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
|
||||
@@ -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 = getElementMap(prevElements);
|
||||
const prevElementMap = arrayToMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = getElementMap(nextElements);
|
||||
const nextElementMap = arrayToMap(nextElements);
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
prevElementMap.get(nextElement.id) || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
@@ -15,7 +15,10 @@ export const actionSelectAll = register({
|
||||
...appState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId)
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
|
||||
@@ -80,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionEditImageAlpha } from "./actionImageEditing";
|
||||
|
||||
@@ -101,7 +101,8 @@ export type ActionName =
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
| "toggleTheme"
|
||||
| "editImageAlpha";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
@@ -41,6 +41,7 @@ export const getDefaultAppState = (): Omit<
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
editingImageElement: null,
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
errorMessage: null,
|
||||
@@ -125,6 +126,7 @@ 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 },
|
||||
|
||||
+2
-1
@@ -58,7 +58,8 @@ export const copyToClipboard = async (
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
// select binded text elements when copying
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: selectedElements,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
@@ -105,6 +106,13 @@ export const SelectedShapeActions = ({
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
|
||||
<fieldset>
|
||||
<div className="buttonList">
|
||||
{targetElements.some((element) => isImageElement(element)) &&
|
||||
renderAction("editImageAlpha")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{renderAction("changeOpacity")}
|
||||
|
||||
<fieldset>
|
||||
|
||||
+346
-108
@@ -120,6 +120,7 @@ import {
|
||||
} from "../element/mutateElement";
|
||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindingElement,
|
||||
isBindingElementType,
|
||||
isImageElement,
|
||||
@@ -174,7 +175,7 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { SceneState, ScrollBars } from "../scene/types";
|
||||
import { RenderConfig, ScrollBars } from "../scene/types";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import {
|
||||
@@ -194,6 +195,7 @@ import {
|
||||
import {
|
||||
debounce,
|
||||
distance,
|
||||
getFontString,
|
||||
getNearestScrollableContainer,
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
@@ -228,6 +230,14 @@ import {
|
||||
} 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);
|
||||
@@ -272,7 +282,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
UIOptions: DEFAULT_UI_OPTIONS,
|
||||
};
|
||||
|
||||
private scene: Scene;
|
||||
public scene: Scene;
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
@@ -1022,8 +1032,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
||||
(this.state.editingLinearElement &&
|
||||
!this.state.selectedElementIds[
|
||||
this.state.editingLinearElement.elementId
|
||||
]) ||
|
||||
(this.state.editingImageElement &&
|
||||
!this.state.selectedElementIds[
|
||||
this.state.editingImageElement.elementId
|
||||
])
|
||||
) {
|
||||
// defer so that the commitToHistory flag isn't reset via current update
|
||||
setTimeout(() => {
|
||||
@@ -1053,8 +1069,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
|
||||
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
|
||||
const pointerViewportCoords: RenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remoteSelectedElementIds: RenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
this.state.collaborators.forEach((user, socketId) => {
|
||||
@@ -1122,10 +1140,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||
theme: this.state.theme,
|
||||
imageCache: this.imageCache,
|
||||
},
|
||||
{
|
||||
renderOptimizations: true,
|
||||
isExporting: false,
|
||||
renderScrollbars: !this.isMobile,
|
||||
editingImageElement: this.state.editingImageElement,
|
||||
},
|
||||
);
|
||||
if (scrollBars) {
|
||||
@@ -1133,7 +1150,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const scrolledOutside =
|
||||
// hide when editing text
|
||||
this.state.editingElement?.type === "text"
|
||||
isTextElement(this.state.editingElement)
|
||||
? false
|
||||
: !atLeastOneVisibleElement && renderingElements.length > 0;
|
||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||
@@ -1375,6 +1392,7 @@ 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,
|
||||
@@ -1393,7 +1411,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...this.state,
|
||||
isLibraryOpen: false,
|
||||
selectedElementIds: newElements.reduce((map, element) => {
|
||||
map[element.id] = true;
|
||||
if (isTextElement(element) && !element.containerId) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
selectedGroupIds: {},
|
||||
@@ -1709,9 +1729,11 @@ 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;
|
||||
@@ -1866,14 +1888,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isExistingElement?: boolean;
|
||||
},
|
||||
) {
|
||||
const updateElement = (text: string, isDeleted = false) => {
|
||||
const updateElement = (
|
||||
text: string,
|
||||
originalText: string,
|
||||
isDeleted = false,
|
||||
updateDimensions = false,
|
||||
) => {
|
||||
this.scene.replaceAllElements([
|
||||
...this.scene.getElementsIncludingDeleted().map((_element) => {
|
||||
if (_element.id === element.id && isTextElement(_element)) {
|
||||
return updateTextElement(_element, {
|
||||
text,
|
||||
isDeleted,
|
||||
});
|
||||
return updateTextElement(
|
||||
_element,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
originalText,
|
||||
},
|
||||
updateDimensions,
|
||||
);
|
||||
}
|
||||
return _element;
|
||||
}),
|
||||
@@ -1898,21 +1930,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
];
|
||||
},
|
||||
onChange: withBatchedUpdates((text) => {
|
||||
updateElement(text);
|
||||
updateElement(text, text, false, !element.containerId);
|
||||
if (isNonDeletedElement(element)) {
|
||||
updateBoundElements(element);
|
||||
}
|
||||
}),
|
||||
onSubmit: withBatchedUpdates(({ text, viaKeyboard }) => {
|
||||
onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
|
||||
const isDeleted = !text.trim();
|
||||
updateElement(text, isDeleted);
|
||||
updateElement(text, originalText, isDeleted, true);
|
||||
// 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,
|
||||
[element.id]: true,
|
||||
[elementIdToSelect]: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -1941,7 +1976,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);
|
||||
updateElement(element.text, element.originalText);
|
||||
}
|
||||
|
||||
private deselectElements() {
|
||||
@@ -1956,7 +1991,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
const element = this.getElementAtPosition(x, y, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
if (element && isTextElement(element) && !element.isDeleted) {
|
||||
return element;
|
||||
@@ -1971,9 +2008,14 @@ 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);
|
||||
const allHitElements = this.getElementsAtPosition(
|
||||
x,
|
||||
y,
|
||||
opts?.includeBoundTextElement,
|
||||
);
|
||||
if (allHitElements.length > 1) {
|
||||
if (opts?.preferSelected) {
|
||||
for (let index = allHitElements.length - 1; index > -1; index--) {
|
||||
@@ -2004,8 +2046,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private getElementsAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
includeBoundTextElement: boolean = false,
|
||||
): NonDeleted<ExcalidrawElement>[] {
|
||||
return getElementsAtPosition(this.scene.getElements(), (element) =>
|
||||
const elements = includeBoundTextElement
|
||||
? this.scene.getElements()
|
||||
: this.scene
|
||||
.getElements()
|
||||
.filter(
|
||||
(element) => !(isTextElement(element) && element.containerId),
|
||||
);
|
||||
return getElementsAtPosition(elements, (element) =>
|
||||
hitTest(element, this.state, x, y),
|
||||
);
|
||||
}
|
||||
@@ -2013,17 +2063,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(
|
||||
@@ -2034,6 +2084,43 @@ 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({
|
||||
@@ -2060,6 +2147,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
verticalAlign: parentCenterPosition
|
||||
? "middle"
|
||||
: DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: container?.id ?? undefined,
|
||||
});
|
||||
|
||||
this.setState({ editingElement: element });
|
||||
@@ -2130,7 +2218,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
resetCursor(this.canvas);
|
||||
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
);
|
||||
@@ -2162,9 +2250,22 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -2237,6 +2338,10 @@ 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
|
||||
@@ -2263,10 +2368,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// and point
|
||||
const { draggingElement } = this.state;
|
||||
if (isBindingElement(draggingElement)) {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
draggingElement,
|
||||
"end",
|
||||
scenePointer,
|
||||
[scenePointer],
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
} else {
|
||||
@@ -2399,6 +2503,21 @@ 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] &&
|
||||
@@ -2736,6 +2855,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
origin,
|
||||
selectedElements,
|
||||
),
|
||||
hasHitElementInside: false,
|
||||
},
|
||||
drag: {
|
||||
hasOccurred: false,
|
||||
@@ -2747,6 +2867,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onKeyUp: null,
|
||||
onKeyDown: null,
|
||||
},
|
||||
boxSelection: {
|
||||
hasOccurred: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2809,6 +2932,14 @@ 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) {
|
||||
@@ -2888,6 +3019,15 @@ 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(
|
||||
@@ -2908,8 +3048,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.clearSelection(hitElement);
|
||||
}
|
||||
|
||||
// If we click on something
|
||||
if (hitElement != null) {
|
||||
if (this.state.editingLinearElement) {
|
||||
this.setState({
|
||||
selectedElementIds: {
|
||||
[this.state.editingLinearElement.elementId]: true,
|
||||
},
|
||||
});
|
||||
// If we click on something
|
||||
} else 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]) {
|
||||
@@ -3002,13 +3148,25 @@ 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 (this.state.editingElement?.type === "text") {
|
||||
if (isTextElement(this.state.editingElement)) {
|
||||
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: pointerDownState.origin.x,
|
||||
sceneY: pointerDownState.origin.y,
|
||||
sceneX,
|
||||
sceneY,
|
||||
shouldBind: false,
|
||||
insertAtParentCenter: !event.altKey,
|
||||
});
|
||||
|
||||
@@ -3342,17 +3500,32 @@ 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, startOrEnd) => {
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
(element, pointsSceneCoords) => {
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
element,
|
||||
startOrEnd,
|
||||
pointerCoords,
|
||||
pointsSceneCoords,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3369,8 +3542,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
|
||||
if (
|
||||
hasHitASelectedElement ||
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
|
||||
(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)
|
||||
) {
|
||||
// Marking that click was used for dragging to check
|
||||
// if elements should be deselected on pointerup
|
||||
@@ -3401,7 +3582,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElements,
|
||||
dragX,
|
||||
dragY,
|
||||
this.scene,
|
||||
lockDirection,
|
||||
dragDistanceX,
|
||||
dragDistanceY,
|
||||
@@ -3421,9 +3601,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const groupIdMap = new Map();
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
for (const element of this.scene.getElementsIncludingDeleted()) {
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const selectedElementIds: Array<ExcalidrawElement["id"]> =
|
||||
getSelectedElements(elements, this.state, true).map(
|
||||
(element) => element.id,
|
||||
);
|
||||
|
||||
for (const element of elements) {
|
||||
if (
|
||||
this.state.selectedElementIds[element.id] ||
|
||||
selectedElementIds.includes(element.id) ||
|
||||
// case: the state.selectedElementIds might not have been
|
||||
// updated yet by the time this mousemove event is fired
|
||||
(element.id === hitElement?.id &&
|
||||
@@ -3451,6 +3637,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||
bindTextToShapeAfterDuplication(
|
||||
nextElements,
|
||||
elementsToAppend,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(
|
||||
nextSceneElements,
|
||||
elementsToAppend,
|
||||
@@ -3507,10 +3698,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (isBindingElement(draggingElement)) {
|
||||
// When creating a linear element by dragging
|
||||
this.maybeSuggestBindingForLinearElementAtCursor(
|
||||
this.maybeSuggestBindingsForLinearElementAtCoords(
|
||||
draggingElement,
|
||||
"end",
|
||||
pointerCoords,
|
||||
[pointerCoords],
|
||||
this.state.startBoundElement,
|
||||
);
|
||||
}
|
||||
@@ -3521,8 +3711,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (this.state.elementType === "selection") {
|
||||
pointerDownState.boxSelection.hasOccurred = true;
|
||||
|
||||
const elements = this.scene.getElements();
|
||||
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
|
||||
if (
|
||||
!event.shiftKey &&
|
||||
// allows for box-selecting points (without shift)
|
||||
!this.state.editingLinearElement &&
|
||||
isSomeElementSelected(elements, this.state)
|
||||
) {
|
||||
if (pointerDownState.withCmdOrCtrl && pointerDownState.hit.element) {
|
||||
this.setState((prevState) =>
|
||||
selectGroupsForSelectedElements(
|
||||
@@ -3543,33 +3740,43 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
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),
|
||||
// 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
this.scene.getElements(),
|
||||
),
|
||||
);
|
||||
this.scene.getElements(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3631,19 +3838,32 @@ 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) {
|
||||
const editingLinearElement = LinearElementEditor.handlePointerUp(
|
||||
childEvent,
|
||||
this.state.editingLinearElement,
|
||||
this.state,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({
|
||||
editingLinearElement,
|
||||
suggestedBindings: [],
|
||||
});
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3825,9 +4045,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
hitElement &&
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
!pointerDownState.hit.wasAddedToSelection
|
||||
!pointerDownState.hit.wasAddedToSelection &&
|
||||
// if we're editing a line, pointerup shouldn't switch selection if
|
||||
// box selected
|
||||
(!this.state.editingLinearElement ||
|
||||
!pointerDownState.boxSelection.hasOccurred)
|
||||
) {
|
||||
if (childEvent.shiftKey) {
|
||||
// when inside line editor, shift selects points instead
|
||||
if (childEvent.shiftKey && !this.state.editingLinearElement) {
|
||||
if (this.state.selectedElementIds[hitElement.id]) {
|
||||
if (isSelectedViaGroup(this.state, hitElement)) {
|
||||
// We want to unselect all groups hitElement is part of
|
||||
@@ -3871,6 +4096,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
// add element to selection while
|
||||
// keeping prev elements selected
|
||||
|
||||
this.setState((_prevState) => ({
|
||||
selectedElementIds: {
|
||||
..._prevState.selectedElementIds,
|
||||
@@ -4001,10 +4227,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const existingFileData = this.files[fileId];
|
||||
if (!existingFileData?.dataURL) {
|
||||
try {
|
||||
imageFile = await resizeImageFile(
|
||||
imageFile,
|
||||
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
||||
);
|
||||
imageFile = await resizeImageFile(imageFile, {
|
||||
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("error trying to resing image file on insertion", error);
|
||||
}
|
||||
@@ -4113,7 +4338,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
|
||||
const cursorImageSizePx = 96;
|
||||
|
||||
const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx);
|
||||
const imagePreview = await resizeImageFile(imageFile, {
|
||||
maxWidthOrHeight: cursorImageSizePx,
|
||||
});
|
||||
|
||||
let previewDataURL = await getDataURL(imagePreview);
|
||||
|
||||
@@ -4351,32 +4578,43 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForLinearElementAtCursor = (
|
||||
private maybeSuggestBindingsForLinearElementAtCoords = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
/** scene coords */
|
||||
pointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
}[],
|
||||
// During line creation the start binding hasn't been written yet
|
||||
// into `linearElement`
|
||||
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||
): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene,
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
this.setState({
|
||||
suggestedBindings:
|
||||
hoveredBindableElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBound(
|
||||
linearElement,
|
||||
oppositeBindingBoundElement?.id,
|
||||
hoveredBindableElement,
|
||||
)
|
||||
? [hoveredBindableElement]
|
||||
: [],
|
||||
});
|
||||
|
||||
this.setState({ suggestedBindings });
|
||||
};
|
||||
|
||||
private maybeSuggestBindingForAll(
|
||||
|
||||
+11
-4
@@ -3,15 +3,22 @@ import OpenColor from "open-color";
|
||||
import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
color: keyof OpenColor | "primary";
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
className="Card"
|
||||
style={{
|
||||
["--card-color" as any]: OpenColor[color][7],
|
||||
["--card-color-darker" as any]: OpenColor[color][8],
|
||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
||||
["--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],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -6,14 +6,14 @@ import "./CheckboxItem.scss";
|
||||
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
onChange(!checked, event);
|
||||
(
|
||||
(event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
@@ -60,13 +61,18 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.rotate");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (appState.editingLinearElement) {
|
||||
return appState.editingLinearElement.activePointIndex
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
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");
|
||||
}
|
||||
return t("hints.lineEditor_info");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: transparent;
|
||||
background-color: var(--button-gray-2);
|
||||
& svg {
|
||||
|
||||
@@ -102,7 +102,7 @@ const ImageExportModal = ({
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
? getSelectedElements(elements, appState, true)
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
@@ -35,6 +34,9 @@ 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;
|
||||
@@ -268,7 +270,7 @@ const LayerUI = ({
|
||||
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState)}
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
@@ -305,7 +307,12 @@ const LayerUI = ({
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
@@ -314,7 +321,9 @@ const LayerUI = ({
|
||||
/>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
className={clsx("App-toolbar", {
|
||||
"zen-mode": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
|
||||
@@ -16,18 +16,18 @@ const LIBRARY_ICON = (
|
||||
export const LibraryButton: React.FC<{
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}> = ({ appState, setAppState }) => {
|
||||
isMobile?: boolean;
|
||||
}> = ({ appState, setAppState, isMobile }) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
||||
"ToolIcon ToolIcon_type_floating ToolIcon__library",
|
||||
`ToolIcon_size_medium`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||
"is-mobile": isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
|
||||
@@ -18,6 +18,7 @@ import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@@ -236,6 +237,10 @@ 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 && (
|
||||
@@ -271,10 +276,44 @@ export const LibraryMenu = ({
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id) => {
|
||||
if (!selectedItems.includes(id)) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
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);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { VERSIONS } from "../constants";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
@@ -51,7 +52,7 @@ const LibraryMenuItems = ({
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onToggle: (id: LibraryItem["id"]) => void;
|
||||
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
@@ -212,10 +213,8 @@ const LibraryMenuItems = ({
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={() => {
|
||||
if (params.item?.id) {
|
||||
onToggle(params.item.id);
|
||||
}
|
||||
onToggle={(id, event) => {
|
||||
onToggle(id, event);
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
@@ -293,7 +292,9 @@ const LibraryMenuItems = ({
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
|
||||
@@ -99,8 +99,13 @@
|
||||
margin-top: -10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.library-unit--hover .library-unit__adder {
|
||||
color: $oc-blue-7;
|
||||
.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__active {
|
||||
|
||||
@@ -8,12 +8,15 @@ import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
// fa-plus
|
||||
const PLUS_ICON = (
|
||||
<svg viewBox="0 0 1792 1792">
|
||||
<path
|
||||
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"
|
||||
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)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -33,7 +36,7 @@ export const LibraryUnit = ({
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -84,7 +87,17 @@ export const LibraryUnit = ({
|
||||
})}
|
||||
ref={ref}
|
||||
draggable={!!elements}
|
||||
onClick={!!elements || !!isPending ? onClick : undefined}
|
||||
onClick={
|
||||
!!elements || !!isPending
|
||||
? (event) => {
|
||||
if (id && event.shiftKey) {
|
||||
onToggle(id, event);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
setIsHovered(false);
|
||||
event.dataTransfer.setData(
|
||||
@@ -97,7 +110,7 @@ export const LibraryUnit = ({
|
||||
{id && elements && (isHovered || isMobile || selected) && (
|
||||
<CheckboxItem
|
||||
checked={selected}
|
||||
onChange={() => onToggle(id)}
|
||||
onChange={(checked, event) => onToggle(id, event)}
|
||||
className="library-unit__checkbox"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ type LockIconProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
zenModeEnabled?: boolean;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
@@ -42,10 +43,10 @@ export const LockButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||
"is-mobile": props.isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${props.title} — Q`}
|
||||
|
||||
@@ -64,8 +64,8 @@ export const MobileMenu = ({
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1}>
|
||||
<Stack.Row gap={1} className="App-toolbar-container">
|
||||
<Island padding={1} className="App-toolbar">
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
@@ -85,8 +85,13 @@ 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import oc from "open-color";
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
@@ -7,16 +7,19 @@ import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { exportToBlob } from "../packages/utils";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
import {
|
||||
EXPORT_DATA_TYPES,
|
||||
EXPORT_SOURCE,
|
||||
MIME_TYPES,
|
||||
VERSIONS,
|
||||
} 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;
|
||||
@@ -55,6 +58,75 @@ 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,
|
||||
@@ -129,59 +201,12 @@ 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 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 previewImage = await generatePreviewImage(clonedLibItems);
|
||||
|
||||
const libContent: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 2,
|
||||
version: VERSIONS.excalidrawLibrary,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems: clonedLibItems,
|
||||
};
|
||||
@@ -190,7 +215,8 @@ const PublishLibrary = ({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("excalidrawLib", lib);
|
||||
formData.append("excalidrawPng", png!);
|
||||
formData.append("previewImage", previewImage);
|
||||
formData.append("previewImageType", previewImage.type);
|
||||
formData.append("title", libraryData.name);
|
||||
formData.append("authorName", libraryData.authorName);
|
||||
formData.append("githubHandle", libraryData.githubHandle);
|
||||
|
||||
@@ -8,17 +8,7 @@
|
||||
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 {
|
||||
@@ -29,6 +19,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -38,7 +42,11 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border-radius: var(--space-factor);
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
& + .ToolIcon__label {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
@@ -46,10 +54,6 @@
|
||||
fill: var(--icon-fill-color);
|
||||
color: var(--icon-fill-color);
|
||||
}
|
||||
|
||||
& + .ToolIcon__label {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__label {
|
||||
@@ -79,7 +83,7 @@
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .ToolIcon__icon {
|
||||
&:focus-visible + .ToolIcon__icon {
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
|
||||
@@ -141,10 +145,6 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
background-color: var(--button-gray-1);
|
||||
&:hover {
|
||||
@@ -159,13 +159,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: var(--space-factor);
|
||||
&.ToolIcon_type_floating {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
@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};
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
// wraps the element we want to apply the tooltip to
|
||||
.excalidraw-tooltip-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
|
||||
@@ -62,9 +62,15 @@ type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
long?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
label,
|
||||
long = false,
|
||||
style,
|
||||
}: TooltipProps) => {
|
||||
useEffect(() => {
|
||||
return () =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||
@@ -84,6 +90,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
onPointerLeave={() =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||
}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.UserList > * {
|
||||
|
||||
@@ -15,8 +15,9 @@ import { THEME } from "../constants";
|
||||
|
||||
const activeElementColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.orange[4] : oc.orange[9];
|
||||
const iconFillColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.black : oc.gray[4];
|
||||
|
||||
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
|
||||
const handlerColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||
|
||||
@@ -88,6 +89,14 @@ 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",
|
||||
|
||||
@@ -176,3 +176,10 @@ 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;
|
||||
|
||||
+10
-8
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.buttonList label:focus-within,
|
||||
input:focus {
|
||||
input:focus-visible {
|
||||
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: 4px;
|
||||
border-radius: var(--border-radius-md);
|
||||
margin: 0.125rem 0;
|
||||
padding: 0.25rem;
|
||||
white-space: nowrap;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: transparent;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
@@ -217,14 +217,16 @@
|
||||
|
||||
.active,
|
||||
.buttonList label.active {
|
||||
background-color: var(--button-gray-2);
|
||||
background-color: var(--color-primary);
|
||||
|
||||
--icon-fill-color: #{$oc-white};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +236,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
svg {
|
||||
width: 36px;
|
||||
width: 35px;
|
||||
height: 14px;
|
||||
padding: 2px;
|
||||
opacity: 0.6;
|
||||
@@ -311,7 +313,7 @@
|
||||
}
|
||||
|
||||
.App-menu_top {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-columns: auto max-content auto;
|
||||
grid-gap: 4px;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
|
||||
+19
-3
@@ -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-black};
|
||||
--icon-fill-color: #{$oc-gray-9};
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
@@ -32,10 +32,20 @@
|
||||
--sar: env(safe-area-inset-right);
|
||||
--sat: env(safe-area-inset-top);
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
||||
--shadow-island: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 12%);
|
||||
|
||||
--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;
|
||||
|
||||
@@ -71,7 +81,13 @@
|
||||
--popup-text-color: #{$oc-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$oc-blue-4};
|
||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
||||
--shadow-island: 1px 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;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-4
@@ -237,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
||||
|
||||
export const resizeImageFile = async (
|
||||
file: File,
|
||||
maxWidthOrHeight: number,
|
||||
opts: {
|
||||
/** undefined indicates auto */
|
||||
outputType?: typeof MIME_TYPES["jpg"];
|
||||
maxWidthOrHeight: number;
|
||||
},
|
||||
): Promise<File> => {
|
||||
// SVG files shouldn't a can't be resized
|
||||
if (file.type === MIME_TYPES.svg) {
|
||||
@@ -257,16 +261,26 @@ export const resizeImageFile = async (
|
||||
pica: pica({ features: ["js", "wasm"] }),
|
||||
});
|
||||
|
||||
const fileType = file.type;
|
||||
if (opts.outputType) {
|
||||
const { outputType } = opts;
|
||||
reduce._create_blob = function (env) {
|
||||
return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
|
||||
env.out_blob = blob;
|
||||
return env;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSupportedImageFile(file)) {
|
||||
throw new Error(t("errors.unsupportedFileType"));
|
||||
}
|
||||
|
||||
return new File(
|
||||
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
|
||||
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
||||
file.name,
|
||||
{ type: fileType },
|
||||
{
|
||||
type: opts.outputType || file.type,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+13
-1
@@ -234,7 +234,19 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
// first chunk is the version (ignored for now)
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
cursor += VERSION_DATAVIEW_BYTES;
|
||||
|
||||
while (true) {
|
||||
|
||||
+8
-3
@@ -1,6 +1,11 @@
|
||||
import { fileOpen, fileSave } from "./filesystem";
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import {
|
||||
EXPORT_DATA_TYPES,
|
||||
EXPORT_SOURCE,
|
||||
MIME_TYPES,
|
||||
VERSIONS,
|
||||
} from "../constants";
|
||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
@@ -42,7 +47,7 @@ export const serializeAsJSON = (
|
||||
): string => {
|
||||
const data: ExportedDataState = {
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
version: VERSIONS.excalidraw,
|
||||
source: EXPORT_SOURCE,
|
||||
elements:
|
||||
type === "local"
|
||||
@@ -121,7 +126,7 @@ export const isValidLibrary = (json: any) => {
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 2,
|
||||
version: VERSIONS.excalidrawLibrary,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems,
|
||||
};
|
||||
|
||||
+15
-9
@@ -10,11 +10,7 @@ import {
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getElementMap,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
} from "../element";
|
||||
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@@ -26,6 +22,8 @@ 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,
|
||||
@@ -66,7 +64,10 @@ const restoreElementWithProperties = <
|
||||
T extends ExcalidrawElement,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: Required<T>,
|
||||
element: Required<T> & {
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
},
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
@@ -100,7 +101,10 @@ const restoreElementWithProperties = <
|
||||
strokeSharpness:
|
||||
element.strokeSharpness ??
|
||||
(isLinearElementType(element.type) ? "round" : "sharp"),
|
||||
boundElementIds: element.boundElementIds ?? [],
|
||||
boundElements: element.boundElementIds
|
||||
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -131,6 +135,8 @@ 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, {
|
||||
@@ -204,14 +210,14 @@ export const restoreElements = (
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
): ExcalidrawElement[] => {
|
||||
const localElementsMap = localElements ? getElementMap(localElements) : null;
|
||||
const localElementsMap = localElements ? arrayToMap(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?.[element.id];
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -24,7 +25,7 @@ export interface ImportedDataState {
|
||||
|
||||
export interface ExportedLibraryData {
|
||||
type: string;
|
||||
version: 2;
|
||||
version: typeof VERSIONS.excalidrawLibrary;
|
||||
source: string;
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
|
||||
+90
-65
@@ -8,7 +8,11 @@ import {
|
||||
} from "./types";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { isBindableElement, isBindingElement } from "./typeChecks";
|
||||
import {
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
bindingBorderTest,
|
||||
distanceToBindableElement,
|
||||
@@ -20,7 +24,7 @@ import {
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export type SuggestedBinding =
|
||||
@@ -74,8 +78,9 @@ export const bindOrUnbindLinearElement = (
|
||||
.getNonDeletedElements(onlyUnbound)
|
||||
.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
boundElementIds: element.boundElementIds?.filter(
|
||||
(id) => id !== linearElement.id,
|
||||
boundElements: element.boundElements?.filter(
|
||||
(element) =>
|
||||
element.type !== "arrow" || element.id !== linearElement.id,
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -180,11 +185,16 @@ const bindLinearElement = (
|
||||
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
||||
} as PointBinding,
|
||||
});
|
||||
mutateElement(hoveredElement, {
|
||||
boundElementIds: Array.from(
|
||||
new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
|
||||
),
|
||||
});
|
||||
|
||||
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
|
||||
if (!boundElementsMap.has(linearElement.id)) {
|
||||
mutateElement(hoveredElement, {
|
||||
boundElements: (hoveredElement.boundElements || []).concat({
|
||||
id: linearElement.id,
|
||||
type: "arrow",
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Don't bind both ends of a simple segment
|
||||
@@ -284,52 +294,56 @@ export const updateBoundElements = (
|
||||
newSize?: { width: number; height: number };
|
||||
},
|
||||
) => {
|
||||
const boundElementIds = changedElement.boundElementIds ?? [];
|
||||
if (boundElementIds.length === 0) {
|
||||
const boundLinearElements = (changedElement.boundElements ?? []).filter(
|
||||
(el) => el.type === "arrow",
|
||||
);
|
||||
if (boundLinearElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
(
|
||||
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,
|
||||
);
|
||||
});
|
||||
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,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const doesNeedUpdate = (
|
||||
@@ -401,10 +415,17 @@ const updateBoundPoint = (
|
||||
newEdgePoint = intersections[0];
|
||||
}
|
||||
}
|
||||
LinearElementEditor.movePoint(
|
||||
LinearElementEditor.movePoints(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
|
||||
[
|
||||
{
|
||||
index: edgePointIndex,
|
||||
point: LinearElementEditor.pointFromAbsoluteCoords(
|
||||
linearElement,
|
||||
newEdgePoint,
|
||||
),
|
||||
},
|
||||
],
|
||||
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
||||
);
|
||||
};
|
||||
@@ -552,11 +573,11 @@ export const fixBindingsAfterDuplication = (
|
||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||
oldElements.forEach((oldElement) => {
|
||||
const { boundElementIds } = oldElement;
|
||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||
boundElementIds.forEach((boundElementId) => {
|
||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
|
||||
allBoundElementIds.add(boundElementId);
|
||||
const { boundElements } = oldElement;
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
boundElements.forEach((boundElement) => {
|
||||
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
|
||||
allBoundElementIds.add(boundElement.id);
|
||||
}
|
||||
});
|
||||
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
||||
@@ -600,12 +621,16 @@ export const fixBindingsAfterDuplication = (
|
||||
sceneElements
|
||||
.filter(({ id }) => allBindableElementIds.has(id))
|
||||
.forEach((bindableElement) => {
|
||||
const { boundElementIds } = bindableElement;
|
||||
if (boundElementIds != null && boundElementIds.length > 0) {
|
||||
const { boundElements } = bindableElement;
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
mutateElement(bindableElement, {
|
||||
boundElementIds: boundElementIds.map(
|
||||
(boundElementId) =>
|
||||
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
|
||||
boundElements: boundElements.map((boundElement) =>
|
||||
oldIdToDuplicatedId.has(boundElement.id)
|
||||
? {
|
||||
id: oldIdToDuplicatedId.get(boundElement.id)!,
|
||||
type: boundElement.type,
|
||||
}
|
||||
: boundElement,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -638,9 +663,9 @@ export const fixBindingsAfterDeletion = (
|
||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
deletedElements.forEach((deletedElement) => {
|
||||
if (isBindableElement(deletedElement)) {
|
||||
deletedElement.boundElementIds?.forEach((id) => {
|
||||
if (!deletedElementIds.has(id)) {
|
||||
boundElementIds.add(id);
|
||||
deletedElement.boundElements?.forEach((element) => {
|
||||
if (!deletedElementIds.has(element.id)) {
|
||||
boundElementIds.add(element.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+12
-11
@@ -31,7 +31,9 @@ import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { isImageElement } from "./typeChecks";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -43,9 +45,9 @@ const isElementDraggableFromInside = (
|
||||
if (element.type === "freedraw") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isDraggableFromInside = element.backgroundColor !== "transparent";
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
(isTransparent(element.backgroundColor) && hasBoundTextElement(element));
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
@@ -83,19 +85,18 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
);
|
||||
};
|
||||
|
||||
const isHittingElementNotConsideringBoundingBox = (
|
||||
export const isHittingElementNotConsideringBoundingBox = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
||||
const check =
|
||||
element.type === "text"
|
||||
? isStrictlyInside
|
||||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
const check = isTextElement(element)
|
||||
? isStrictlyInside
|
||||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
};
|
||||
|
||||
+49
-18
@@ -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,30 +20,61 @@ export const dragSelectedElements = (
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
selectedElements.forEach((element) => {
|
||||
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;
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -109,3 +109,16 @@ export const normalizeSVG = async (SVGString: string) => {
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const image = new Image();
|
||||
const dataURL = canvas.toDataURL() as DataURL;
|
||||
image.src = dataURL;
|
||||
return { image, dataURL };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -59,15 +59,6 @@ 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);
|
||||
|
||||
|
||||
+417
-102
@@ -25,11 +25,19 @@ export class LinearElementEditor {
|
||||
public elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
public activePointIndex: number | null;
|
||||
/** indices */
|
||||
public selectedPointsIndices: readonly number[] | null;
|
||||
|
||||
public pointerDownState: Readonly<{
|
||||
prevSelectedPointsIndices: readonly number[] | null;
|
||||
/** index */
|
||||
lastClickedPoint: number;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
public isDragging: boolean;
|
||||
public lastUncommittedPoint: Point | null;
|
||||
public pointerOffset: { x: number; y: number };
|
||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
|
||||
@@ -40,12 +48,16 @@ export class LinearElementEditor {
|
||||
Scene.mapElementToScene(this.elementId, scene);
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
|
||||
this.activePointIndex = null;
|
||||
this.selectedPointsIndices = 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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,6 +78,58 @@ 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,
|
||||
@@ -74,21 +138,27 @@ export class LinearElementEditor {
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
): boolean {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const { editingLinearElement } = appState;
|
||||
const { activePointIndex, elementId, isDragging } = editingLinearElement;
|
||||
const { selectedPointsIndices, elementId, isDragging } =
|
||||
editingLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activePointIndex != null && activePointIndex > -1) {
|
||||
// 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 (isDragging === false) {
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
@@ -98,18 +168,79 @@ export class LinearElementEditor {
|
||||
});
|
||||
}
|
||||
|
||||
const newPoint = LinearElementEditor.createPointAt(
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
);
|
||||
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
|
||||
|
||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition =
|
||||
pointIndex ===
|
||||
editingLinearElement.pointerDownState.lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
)
|
||||
: ([
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
] as const);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging:
|
||||
pointIndex ===
|
||||
editingLinearElement.pointerDownState.lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element)) {
|
||||
maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -118,45 +249,79 @@ export class LinearElementEditor {
|
||||
editingLinearElement: LinearElementEditor,
|
||||
appState: AppState,
|
||||
): LinearElementEditor {
|
||||
const { elementId, activePointIndex, isDragging } = editingLinearElement;
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
? getHoveredElementForBinding(
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
activePointIndex!,
|
||||
),
|
||||
),
|
||||
Scene.getScene(element)!,
|
||||
)
|
||||
: null;
|
||||
binding = {
|
||||
[activePointIndex === 0 ? "startBindingElement" : "endBindingElement"]:
|
||||
bindingElement,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...binding,
|
||||
...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,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
};
|
||||
@@ -206,7 +371,12 @@ export class LinearElementEditor {
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
activePointIndex: element.points.length - 1,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
appState.editingLinearElement.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
@@ -259,10 +429,28 @@ 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,
|
||||
activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
appState.editingLinearElement.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
@@ -292,7 +480,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoint(element, points.length - 1, "delete");
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||
}
|
||||
@@ -305,13 +493,14 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoint(
|
||||
element,
|
||||
element.points.length - 1,
|
||||
newPoint,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.movePoint(element, "new", newPoint);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -320,6 +509,21 @@ 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>,
|
||||
) {
|
||||
@@ -439,22 +643,122 @@ export class LinearElementEditor {
|
||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
static movePointByOffset(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndex: number,
|
||||
offset: { x: number; y: number },
|
||||
) {
|
||||
const [x, y] = element.points[pointIndex];
|
||||
LinearElementEditor.movePoint(element, pointIndex, [
|
||||
x + offset.x,
|
||||
y + offset.y,
|
||||
]);
|
||||
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 movePoint(
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndex: number | "new",
|
||||
targetPosition: Point | "delete",
|
||||
pointIndices: readonly 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);
|
||||
}
|
||||
|
||||
static addPoints(
|
||||
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 }[],
|
||||
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||
) {
|
||||
const { points } = element;
|
||||
@@ -467,49 +771,50 @@ export class LinearElementEditor {
|
||||
let offsetX = 0;
|
||||
let offsetY = 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;
|
||||
const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
|
||||
|
||||
return [point[0] + deltaX, point[1] + deltaY] as const;
|
||||
}
|
||||
return offsetX || offsetY
|
||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||
: point;
|
||||
});
|
||||
if (selectedOriginPoint) {
|
||||
offsetX =
|
||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
||||
offsetY =
|
||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -517,7 +822,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
const prevCoords = getElementPointsCoords(
|
||||
element,
|
||||
points,
|
||||
element.points,
|
||||
element.strokeSharpness || "round",
|
||||
);
|
||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||
@@ -536,3 +841,13 @@ 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;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>,
|
||||
@@ -92,6 +93,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
@@ -126,13 +128,14 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
updated: getUpdatedTimestamp(),
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutates element and updates `version` & `versionNonce`.
|
||||
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
@@ -142,5 +145,6 @@ export const bumpVersion = (
|
||||
) => {
|
||||
element.version = (version ?? element.version) + 1;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
return element;
|
||||
};
|
||||
|
||||
+81
-35
@@ -11,23 +11,28 @@ import {
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawRectangleElement,
|
||||
} from "../element/types";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { getFontString, getUpdatedTimestamp } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { mutateElement, 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">,
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "boundElementIds"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
@@ -50,32 +55,36 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
strokeSharpness,
|
||||
boundElementIds = null,
|
||||
boundElements = null,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => ({
|
||||
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,
|
||||
});
|
||||
) => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const newElement = (
|
||||
opts: {
|
||||
@@ -113,6 +122,7 @@ export const newTextElement = (
|
||||
fontFamily: FontFamilyValues;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId?: ExcalidrawRectangleElement["id"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const metrics = measureText(opts.text, getFontString(opts));
|
||||
@@ -130,6 +140,8 @@ export const newTextElement = (
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: opts.text,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -146,18 +158,25 @@ const getAdjustedDimensions = (
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const maxWidth = element.containerId ? element.width : null;
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element));
|
||||
} = measureText(nextText, getFontString(element), maxWidth);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
if (textAlign === "center" && verticalAlign === "middle") {
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === "middle" &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
@@ -194,6 +213,22 @@ 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,
|
||||
@@ -205,12 +240,22 @@ const getAdjustedDimensions = (
|
||||
|
||||
export const updateTextElement = (
|
||||
element: ExcalidrawTextElement,
|
||||
{ text, isDeleted }: { text: string; isDeleted?: boolean },
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
originalText,
|
||||
}: { text: string; isDeleted?: boolean; originalText: string },
|
||||
|
||||
updateDimensions: boolean,
|
||||
): ExcalidrawTextElement => {
|
||||
const dimensions = updateDimensions
|
||||
? getAdjustedDimensions(element, text)
|
||||
: undefined;
|
||||
return newElementWith(element, {
|
||||
text,
|
||||
originalText,
|
||||
isDeleted: isDeleted ?? element.isDeleted,
|
||||
...getAdjustedDimensions(element, text),
|
||||
...dimensions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -337,6 +382,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
} else {
|
||||
copy.id = randomId();
|
||||
}
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.seed = randomInteger();
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { getFontString } from "../utils";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import {
|
||||
TransformHandleType,
|
||||
@@ -33,6 +33,13 @@ 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) {
|
||||
@@ -132,6 +139,7 @@ export const transformElements = (
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
handleBindTextResize(selectedElements, transformHandleType);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -154,6 +162,11 @@ 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
|
||||
@@ -272,6 +285,7 @@ const measureFontSizeFromWH = (
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
element.containerId ? element.width : null,
|
||||
);
|
||||
return {
|
||||
size: nextFontSize,
|
||||
@@ -413,6 +427,9 @@ export const resizeSingleElement = (
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
const boundsCurrentHeight = esy2 - esy1;
|
||||
|
||||
@@ -473,6 +490,11 @@ 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)) {
|
||||
@@ -565,9 +587,16 @@ export const resizeSingleElement = (
|
||||
],
|
||||
});
|
||||
}
|
||||
let minWidth = 0;
|
||||
if (boundTextElementId) {
|
||||
const boundTextElement = Scene.getScene(element)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElement;
|
||||
minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.width > minWidth &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
@@ -576,6 +605,7 @@ export const resizeSingleElement = (
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
mutateElement(element, resizedElement);
|
||||
handleBindTextResize([element], transformHandleDirection);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -647,7 +677,7 @@ const resizeMultipleElements = (
|
||||
const width = element.width * scale;
|
||||
const height = element.height * scale;
|
||||
let font: { fontSize?: number; baseline?: number } = {};
|
||||
if (element.type === "text") {
|
||||
if (isTextElement(element)) {
|
||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
@@ -728,6 +758,16 @@ 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),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+380
-3
@@ -1,12 +1,389 @@
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { ExcalidrawTextElement } from "./types";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { PADDING } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
|
||||
export const redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
|
||||
const metrics = measureText(element.text, getFontString(element));
|
||||
let maxWidth;
|
||||
if (element.containerId) {
|
||||
maxWidth = element.width;
|
||||
}
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
+247
-43
@@ -1,10 +1,25 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isWritableElement, getFontString } from "../utils";
|
||||
import {
|
||||
isWritableElement,
|
||||
getFontString,
|
||||
viewportCoordsToSceneCoords,
|
||||
getFontFamilyString,
|
||||
} from "../utils";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from "./typeChecks";
|
||||
import { CLASSES } from "../constants";
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { isBoundToContainer, isTextElement } from "./typeChecks";
|
||||
import { CLASSES, PADDING } from "../constants";
|
||||
import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "./types";
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
|
||||
const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -48,55 +63,154 @@ export const textWysiwyg = ({
|
||||
id: ExcalidrawElement["id"];
|
||||
appState: AppState;
|
||||
onChange?: (text: string) => void;
|
||||
onSubmit: (data: { text: string; viaKeyboard: boolean }) => void;
|
||||
onSubmit: (data: {
|
||||
text: string;
|
||||
viaKeyboard: boolean;
|
||||
originalText: string;
|
||||
}) => 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)) {
|
||||
const [viewportX, viewportY] = getViewportCoords(
|
||||
updatedElement.x,
|
||||
updatedElement.y,
|
||||
);
|
||||
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 { textAlign, angle } = updatedElement;
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
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;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${updatedElement.width}px`,
|
||||
height: `${updatedElement.height}px`,
|
||||
width: `${width}px`,
|
||||
height: `${Math.max(editable.clientHeight, updatedElement.height)}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
updatedElement.width,
|
||||
updatedElement.height,
|
||||
angle,
|
||||
appState,
|
||||
maxWidth,
|
||||
),
|
||||
transform: getTransform(width, height, angle, appState, maxWidth),
|
||||
textAlign,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
maxWidth: `${maxWidth}px`,
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -110,6 +224,13 @@ 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",
|
||||
@@ -122,16 +243,21 @@ 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));
|
||||
};
|
||||
}
|
||||
@@ -174,7 +300,7 @@ export const textWysiwyg = ({
|
||||
const linesStartIndices = getSelectedLinesStartIndices();
|
||||
|
||||
let value = editable.value;
|
||||
linesStartIndices.forEach((startIndex) => {
|
||||
linesStartIndices.forEach((startIndex: number) => {
|
||||
const startValue = value.slice(0, startIndex);
|
||||
const endValue = value.slice(startIndex);
|
||||
|
||||
@@ -274,9 +400,63 @@ 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(editable.value),
|
||||
text: normalizeText(wrappedText),
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
originalText: editable.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -305,26 +485,45 @@ export const textWysiwyg = ({
|
||||
editable.remove();
|
||||
};
|
||||
|
||||
const bindBlurEvent = () => {
|
||||
const bindBlurEvent = (event?: MouseEvent) => {
|
||||
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
|
||||
editable.focus();
|
||||
if (!isTargetColorPicker) {
|
||||
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)
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isTargetColorPicker
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
@@ -337,7 +536,12 @@ export const textWysiwyg = ({
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||||
updateWysiwygStyle();
|
||||
editable.focus();
|
||||
const isColorPickerActive = !!document.activeElement?.closest(
|
||||
".color-picker-input",
|
||||
);
|
||||
if (!isColorPickerActive) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -242,7 +243,7 @@ export const getTransformHandles = (
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
}
|
||||
}
|
||||
} else if (element.type === "text") {
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
@@ -85,7 +86,18 @@ export const isBindableElement = (
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "text")
|
||||
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")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,3 +112,20 @@ 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
|
||||
);
|
||||
};
|
||||
|
||||
+15
-2
@@ -43,8 +43,15 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
/** Ids of (linear) elements that are bound to this element. */
|
||||
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
||||
/** 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;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
@@ -114,6 +121,8 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
@@ -123,6 +132,10 @@ 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="indigo">
|
||||
<Card color="primary">
|
||||
<div className="Card-icon">{excalidrawPlusIcon}</div>
|
||||
<h2>Excalidraw+</h2>
|
||||
<div className="Card-details">
|
||||
|
||||
@@ -11,7 +11,15 @@ import { MIME_TYPES } from "../../constants";
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||
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 = {};
|
||||
}
|
||||
|
||||
let firebasePromise: Promise<typeof import("firebase/app").default> | null =
|
||||
null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { compressData, decompressData } from "../../data/encode";
|
||||
import {
|
||||
decryptData,
|
||||
encryptData,
|
||||
generateEncryptionKey,
|
||||
IV_LENGTH_BYTES,
|
||||
} from "../../data/encryption";
|
||||
@@ -109,9 +109,45 @@ 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,
|
||||
privateKey: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_V2_GET}${id}`);
|
||||
@@ -122,28 +158,28 @@ const importFromBackend = async (
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
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, privateKey);
|
||||
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,
|
||||
};
|
||||
} catch (error: any) {
|
||||
// Fixed IV (old format, backward compatibility)
|
||||
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
|
||||
decrypted = await decryptData(fixedIv, buffer, privateKey);
|
||||
console.warn(
|
||||
"error when decoding shareLink data using the new format:",
|
||||
error,
|
||||
);
|
||||
return legacy_decodeFromBackend({ 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,
|
||||
};
|
||||
} catch (error: any) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
console.error(error);
|
||||
@@ -188,20 +224,14 @@ export const exportToBackend = async (
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
const json = serializeAsJSON(elements, appState, files, "database");
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const encryptionKey = await generateEncryptionKey("string");
|
||||
|
||||
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);
|
||||
const payload = await compressData(
|
||||
new TextEncoder().encode(
|
||||
serializeAsJSON(elements, appState, files, "database"),
|
||||
),
|
||||
{ encryptionKey },
|
||||
);
|
||||
|
||||
try {
|
||||
const filesMap = new Map<FileId, BinaryFileData>();
|
||||
@@ -211,8 +241,6 @@ export const exportToBackend = async (
|
||||
}
|
||||
}
|
||||
|
||||
const encryptionKey = exportedKey.k!;
|
||||
|
||||
const filesToUpload = await encodeFilesForUpload({
|
||||
files: filesMap,
|
||||
encryptionKey,
|
||||
@@ -221,7 +249,7 @@ export const exportToBackend = async (
|
||||
|
||||
const response = await fetch(BACKEND_V2_POST, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
body: payload.buffer,
|
||||
});
|
||||
const json = await response.json();
|
||||
if (json.id) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
.encrypted-icon {
|
||||
border-radius: var(--space-factor);
|
||||
color: var(--icon-green-fill-color);
|
||||
color: var(--color-primary);
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: auto;
|
||||
|
||||
Vendored
+8
-1
@@ -111,10 +111,17 @@ interface Uint8Array {
|
||||
|
||||
// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
|
||||
declare module "image-blob-reduce" {
|
||||
import { PicaResizeOptions } from "pica";
|
||||
import { PicaResizeOptions, Pica } from "pica";
|
||||
namespace ImageBlobReduce {
|
||||
interface ImageBlobReduce {
|
||||
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
|
||||
_create_blob(
|
||||
this: { pica: Pica },
|
||||
env: {
|
||||
out_canvas: HTMLCanvasElement;
|
||||
out_blob: Blob;
|
||||
},
|
||||
): Promise<any>;
|
||||
}
|
||||
|
||||
interface ImageBlobReduceStatic {
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "مشاركة",
|
||||
"showStroke": "إظهار منتقي لون الخط",
|
||||
"showBackground": "إظهار منتقي لون الخلفية",
|
||||
"toggleTheme": "غير النمط"
|
||||
"toggleTheme": "غير النمط",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "إعادة تعيين اللوحة",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "وضع التأمل",
|
||||
"exitZenMode": "إلغاء الوضع الليلى",
|
||||
"cancel": "إلغاء",
|
||||
"clear": "مسح"
|
||||
"clear": "مسح",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "هذا سيُزيل كامل اللوحة. هل أنت متأكد؟",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
|
||||
"lineEditor_pointSelected": "اضغط على حذف لإزالة النقطة، Ctrl Or Cmd+D للتكرار، أو اسحب للانتقال",
|
||||
"lineEditor_nothingSelected": "حدد نقطة لتحريك أو إزالتها، أو اضغط Alt ثم انقر لإضافة نقاط جديدة",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "تعذر عرض المعاينة",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "العرض"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "نسخت الانماط.",
|
||||
"copyToClipboard": "نسخ إلى الحافظة.",
|
||||
"copyToClipboardAsPng": "تم نسخ {{exportSelection}} إلى الحافظة بصيغة PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Нулиране на платно",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Режим Zen",
|
||||
"exitZenMode": "Спиране на Zen режим",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Това ще изчисти цялото платно. Сигурни ли сте?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "Кликнете два пъти или натиснете Enter за да промените точките",
|
||||
"lineEditor_pointSelected": "Натиснете Delete за да изтриете точка, CtrlOrCmd+D за дуплициране, или извлачете за да преместите",
|
||||
"lineEditor_nothingSelected": "Изберете точка за местене или изтриване, или пък задръжте Alt и натиснете за да добавите нови точки",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Невъзможност за показване на preview",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Широчина"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Копирани стилове.",
|
||||
"copyToClipboard": "Копирано в клипборда.",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Comparteix",
|
||||
"showStroke": "Mostra el selector de color del traç",
|
||||
"showBackground": "Mostra el selector de color de fons",
|
||||
"toggleTheme": "Activa o desactiva el tema"
|
||||
"toggleTheme": "Activa o desactiva el tema",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Neteja el llenç",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Mode zen",
|
||||
"exitZenMode": "Surt de mode zen",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "S'esborrarà tot el llenç. N'esteu segur?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge",
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "No es pot mostrar la previsualització",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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 d’Excalidraw no els veuran mai.",
|
||||
"link": "Article del blog sobre encriptació d'extrem a extrem a Excalidraw"
|
||||
@@ -289,6 +345,7 @@
|
||||
"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}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Sdílet",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "Přepnout tmavý řežim"
|
||||
"toggleTheme": "Přepnout tmavý řežim",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen mód",
|
||||
"exitZenMode": "Opustit zen mód",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Del",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Bredde"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Kopieret stilarter.",
|
||||
"copyToClipboard": "Kopieret til klippebord.",
|
||||
"copyToClipboardAsPng": "Kopieret {{exportSelection}} til klippebord som PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Teilen",
|
||||
"showStroke": "Auswahl für Strichfarbe anzeigen",
|
||||
"showBackground": "Hintergrundfarbe auswählen",
|
||||
"toggleTheme": "Design umschalten"
|
||||
"toggleTheme": "Design umschalten",
|
||||
"personalLib": "Persönliche Bibliothek",
|
||||
"excalidrawLib": "Excalidraw-Bibliothek"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen-Modus",
|
||||
"exitZenMode": "Zen-Modus verlassen",
|
||||
"cancel": "Abbrechen",
|
||||
"clear": "Löschen"
|
||||
"clear": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"publishLibrary": "Veröffentlichen",
|
||||
"submit": "Absenden",
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Dies wird die ganze Zeichenfläche löschen. Bist du dir sicher?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klicken, um das Bild zu platzieren oder klicken und ziehen um seine Größe manuell zu setzen",
|
||||
"publishLibrary": "Veröffentliche deine eigene Bibliothek"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Vorschau kann nicht angezeigt werden",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"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}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Κοινοποίηση",
|
||||
"showStroke": "Εμφάνιση επιλογέα χρωμάτων πινελιάς",
|
||||
"showBackground": "Εμφάνιση επιλογέα χρώματος φόντου",
|
||||
"toggleTheme": "Εναλλαγή θέματος"
|
||||
"toggleTheme": "Εναλλαγή θέματος",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Επαναφορά του καμβά",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Λειτουργία Zεν",
|
||||
"exitZenMode": "Έξοδος από την λειτουργία Zen",
|
||||
"cancel": "Ακύρωση",
|
||||
"clear": "Καθαρισμός"
|
||||
"clear": "Καθαρισμός",
|
||||
"remove": "Κατάργηση",
|
||||
"publishLibrary": "Δημοσίευση",
|
||||
"submit": "Υποβολή",
|
||||
"confirm": "Επιβεβαίωση"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
|
||||
"lineEditor_pointSelected": "Πιέστε Διαγραφή για να αφαιρέσετε το σημείου, CtrlOrCmd+D για να το αντιγράψετε ή σύρτε το για να το μετακινήσετε",
|
||||
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για μετακίνηση ή αφαίρεση, ή κρατήστε παρατεταμένα το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Πλάτος"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Αντιγράφηκαν στυλ.",
|
||||
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
|
||||
"copyToClipboardAsPng": "Αντιγράφηκε {{exportSelection}} στο πρόχειρο ως PNG\n({{exportColorScheme}})",
|
||||
|
||||
+4
-3
@@ -204,10 +204,11 @@
|
||||
"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, 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",
|
||||
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
|
||||
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||
"publishLibrary": "Publish your own library"
|
||||
"publishLibrary": "Publish your own library",
|
||||
"bindTextToElement": "Press enter to add text"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Cannot show preview",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Compartir",
|
||||
"showStroke": "Mostrar selector de color de trazo",
|
||||
"showBackground": "Mostrar el selector de color de fondo",
|
||||
"toggleTheme": "Alternar tema"
|
||||
"toggleTheme": "Alternar tema",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Modo Zen",
|
||||
"exitZenMode": "Salir del modo Zen",
|
||||
"cancel": "Cancelar",
|
||||
"clear": "Borrar"
|
||||
"clear": "Borrar",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Esto limpiará todo el lienzo. Estás seguro?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Haga clic para colocar la imagen o haga clic y arrastre para establecer su tamaño manualmente",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "No se puede mostrar la vista previa",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Ancho"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Estilos copiados.",
|
||||
"copyToClipboard": "Copiado en el portapapeles.",
|
||||
"copyToClipboardAsPng": "Copiado {{exportSelection}} al portapapeles como PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "اشتراکگذاری",
|
||||
"showStroke": "نمایش انتخاب کننده رنگ حاشیه",
|
||||
"showBackground": "نمایش انتخاب کننده رنگ پس زمینه",
|
||||
"toggleTheme": "تغییر تم"
|
||||
"toggleTheme": "تغییر تم",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "پاکسازی بوم نقاشی",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "حالت ذن",
|
||||
"exitZenMode": "خروج از حالت تمرکز",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "این کار کل صفحه را پاک میکند. آیا مطمئنید؟",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "دوبار کلیک کنید یا Enter را فشار دهید تا نقاط را ویرایش کنید",
|
||||
"lineEditor_pointSelected": "برای حذف نقطه Delete برای کپی زدن Ctrl یا Cmd+D را بزنید و یا برای جابجایی بکشید.",
|
||||
"lineEditor_nothingSelected": "یک نقطه را برای جابجایی یا حذف انتخاب کنید یا کلید Alt بگیرید و کلیک کنید تا بتوانید یک نقطه جدید اضافه کنید",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "پیش نمایش نشان داده نمی شود",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "عرض"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "کپی سبک.",
|
||||
"copyToClipboard": "در کلیپبورد کپی شد.",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Jaa",
|
||||
"showStroke": "Näytä viivan värin valitsin",
|
||||
"showBackground": "Näytä taustavärin valitsin",
|
||||
"toggleTheme": "Vaihda teema"
|
||||
"toggleTheme": "Vaihda teema",
|
||||
"personalLib": "Oma kirjasto",
|
||||
"excalidrawLib": "Excalidraw kirjasto"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Tyhjennä piirtoalue",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen-tila",
|
||||
"exitZenMode": "Poistu zen-tilasta",
|
||||
"cancel": "Peruuta",
|
||||
"clear": "Pyyhi"
|
||||
"clear": "Pyyhi",
|
||||
"remove": "Poista",
|
||||
"publishLibrary": "Julkaise",
|
||||
"submit": "Lähetä",
|
||||
"confirm": "Vahvista"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Tämä tyhjentää koko piirtoalueen. Jatketaanko?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
|
||||
"publishLibrary": "Julkaise oma kirjasto"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Esikatselua ei voitu näyttää",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"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}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"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"
|
||||
"toggleTheme": "Changer le thème",
|
||||
"personalLib": "Bibliothèque personnelle",
|
||||
"excalidrawLib": "Bibliothèque Excalidraw"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Réinitialiser le canevas",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Mode zen",
|
||||
"exitZenMode": "Quitter le mode zen",
|
||||
"cancel": "Annuler",
|
||||
"clear": "Effacer"
|
||||
"clear": "Effacer",
|
||||
"remove": "Supprimer",
|
||||
"publishLibrary": "Publier",
|
||||
"submit": "Envoyer",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
|
||||
"publishLibrary": "Publier votre propre bibliothèque"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Impossible d’afficher l’aperçu",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"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}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "שתף",
|
||||
"showStroke": "הצג צבעי קו מתאר",
|
||||
"showBackground": "הצג צבעי רקע",
|
||||
"toggleTheme": "שינוי ערכת העיצוב"
|
||||
"toggleTheme": "שינוי ערכת העיצוב",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "אפס את הלוח",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "מצב זן",
|
||||
"exitZenMode": "צא ממצב תפריט מרחף",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "לא הצלחנו לשחזר את התצוגה מקובץ התמונה",
|
||||
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
|
||||
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "לחץ לחיצה כפולה או אנטר לעריכת הנקודות",
|
||||
"lineEditor_pointSelected": "לחץ על Delete להסרת נקודה, CtrlOrCmd+D לשכפל, או גרור להזזה",
|
||||
"lineEditor_nothingSelected": "בחר נקודה להזזה או הסרה, או החזק את כפתור Alt והקלק להוספת נקודות חדשות",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "לא הצלחנו להציג את התצוגה המקדימה",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "רוחב"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "העתק סגנונות.",
|
||||
"copyToClipboard": "הועתק אל הלוח.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} הועתקה ללוח כ-PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "कैनवास रीसेट करें",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "ज़ेन मोड",
|
||||
"exitZenMode": "जेन मोड से बाहर निकलें",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "इससे पूरा कैनवास साफ हो जाएगा। क्या आपको यकीन है?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "बिंदुओं को संपादित करने के लिए Enter पर डबल-क्लिक करें या दबाएँ",
|
||||
"lineEditor_pointSelected": "बिंदु हटाने के लिए डिलीट दबाएं, प्रतिरूपित करने के लिए कण्ट्रोल या कमांड डी दबाएं या स्थानांतरित करने के लिए खींचे",
|
||||
"lineEditor_nothingSelected": "स्थानांतरित करने या हटाने के लिए एक बिंदु का चयन करें, या Alt दबाए रखें और नए बिंदुओं को जोड़ने के लिए क्लिक करें",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "पूर्वावलोकन नहीं दिखा सकते हैं",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "चौड़ाई"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "काॅपी कीए स्टाइल",
|
||||
"copyToClipboard": "क्लिपबोर्ड में कॉपी कीए",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Vászon törlése",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Letisztult mód",
|
||||
"exitZenMode": "Kilépés a letisztult módból",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Ez a művelet törli a vászont. Biztos benne?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "A jelenet visszaállítása nem sikerült ebből a kép fájlból",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Előnézet nem jeleníthető meg",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Szélesség"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Bagikan",
|
||||
"showStroke": "Tampilkan garis pengambil warna",
|
||||
"showBackground": "Tampilkan latar pengambil warna",
|
||||
"toggleTheme": "Ubah tema"
|
||||
"toggleTheme": "Ubah tema",
|
||||
"personalLib": "Pustaka Pribadi",
|
||||
"excalidrawLib": "Pustaka Excalidraw"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Setel Ulang Kanvas",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Mode zen",
|
||||
"exitZenMode": "Keluar dari mode zen",
|
||||
"cancel": "Batal",
|
||||
"clear": "Hapus"
|
||||
"clear": "Hapus",
|
||||
"remove": "Hapus",
|
||||
"publishLibrary": "Terbitkan",
|
||||
"submit": "Kirimkan",
|
||||
"confirm": "Konfirmasi"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Ini akan menghapus semua yang ada dikanvas. Apakah kamu yakin ?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klik untuk tempatkan gambar, atau klik dan jatuhkan untuk tetapkan ukuran secara manual",
|
||||
"publishLibrary": "Terbitkan pustaka Anda"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Tidak dapat menampilkan pratinjau",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Lebar"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Tambahkan ke pustaka",
|
||||
"copyStyles": "Gaya tersalin.",
|
||||
"copyToClipboard": "Tersalin ke papan klip.",
|
||||
"copyToClipboardAsPng": "Tersalin {{exportSelection}} ke clipboard sebagai PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Condividi",
|
||||
"showStroke": "Mostra selettore colore del tratto",
|
||||
"showBackground": "Mostra selettore colore di sfondo",
|
||||
"toggleTheme": "Cambia tema"
|
||||
"toggleTheme": "Cambia tema",
|
||||
"personalLib": "Libreria Personale",
|
||||
"excalidrawLib": "Libreria di Excalidraw"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Svuota la tela",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Modalità Zen",
|
||||
"exitZenMode": "Uscire dalla modalità zen",
|
||||
"cancel": "Annulla",
|
||||
"clear": "Cancella"
|
||||
"clear": "Cancella",
|
||||
"remove": "Rimuovi",
|
||||
"publishLibrary": "Pubblica",
|
||||
"submit": "Invia",
|
||||
"confirm": "Conferma"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Questa azione cancellerà l'intera tela. Sei sicuro?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Fai click per posizionare l'immagine, o click e trascina per impostarne la dimensione manualmente",
|
||||
"publishLibrary": "Pubblica la tua libreria"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Impossibile visualizzare l'anteprima",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Larghezza"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Aggiunto alla libreria",
|
||||
"copyStyles": "Stili copiati.",
|
||||
"copyToClipboard": "Copiato negli appunti.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} copiato negli appunti come PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "共有",
|
||||
"showStroke": "ストロークカラーピッカーを表示",
|
||||
"showBackground": "背景色ピッカーを表示",
|
||||
"toggleTheme": "テーマの切り替え"
|
||||
"toggleTheme": "テーマの切り替え",
|
||||
"personalLib": "個人ライブラリ",
|
||||
"excalidrawLib": "Excalidrawライブラリ"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "キャンバスのリセット",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zenモード",
|
||||
"exitZenMode": "集中モードをやめる",
|
||||
"cancel": "キャンセル",
|
||||
"clear": "消去"
|
||||
"clear": "消去",
|
||||
"remove": "削除",
|
||||
"publishLibrary": "公開",
|
||||
"submit": "送信",
|
||||
"confirm": "確認"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "この操作によってキャンバス全体が消えます。よろしいですか?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "このイメージファイルからシーンを復元できませんでした",
|
||||
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
|
||||
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
|
||||
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "ポイントを編集するには、ダブルクリックまたはEnterキーを押します",
|
||||
"lineEditor_pointSelected": "削除ボタンを押して点を削除します。Ctrl+D または Cmd+D で複製します。またはドラッグして移動します",
|
||||
"lineEditor_nothingSelected": "移動または削除する点を選択するか、Altキーを押しながらクリックして新しい点を追加します",
|
||||
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します"
|
||||
"placeImage": "クリックして画像を配置するか、クリックしてドラッグしてサイズを手動で設定します",
|
||||
"publishLibrary": "自分のライブラリを公開"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "プレビューを表示できません",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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のエンドツーエンド暗号化に関するブログ記事"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "幅"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "ライブラリに追加しました",
|
||||
"copyStyles": "スタイルをコピーしました。",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} を PNG 形式でクリップボードにコピーしました\n({{exportColorScheme}})",
|
||||
|
||||
@@ -100,7 +100,9 @@
|
||||
"share": "Bḍu",
|
||||
"showStroke": "Beqqeḍ amelqaḍ n yini n yizirig",
|
||||
"showBackground": "Beqqeḍ amelqaḍ n yini n ugilal",
|
||||
"toggleTheme": "Snifel asentel"
|
||||
"toggleTheme": "Snifel asentel",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Ales awennez n teɣzut n usuneɣ",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Askar Zen",
|
||||
"exitZenMode": "Ffeɣ seg uskar Zen",
|
||||
"cancel": "Sefsex",
|
||||
"clear": "Sfeḍ"
|
||||
"clear": "Sfeḍ",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Ayagi ad isfeḍ akk taɣzut n usuneɣ. Tetḥeqqeḍ?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Ssit akken ad tserseḍ tugna, neɣ ssit u zuɣer akken ad tesbaduḍ tiddi-ines s ufus",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Ulamek abeqqeḍ n teskant",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Tehri"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Iɣunab yettwaneɣlen.",
|
||||
"copyToClipboard": "Yettwaɣel ɣer tecfawit.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} yettwanɣel ɣer tecfawit am PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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 қолданатын өтпелі шифрлеу туралы блог жазбасы"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Ені"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Стильдер көшірілді.",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "캔버스 초기화",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "젠 모드",
|
||||
"exitZenMode": "젠 모드 종료하기",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "모든 작업 내용이 초기화됩니다. 계속하시겠습니까?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "이미지 파일에서 화면을 복구할 수 없었습니다",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "지점을 수정하려면 두 번 클릭하거나 Enter 키를 누르세요.",
|
||||
"lineEditor_pointSelected": "제거하려면 Delete 키, 복제하려면 CtrlOrCmd+D, 이동하려면 드래그하세요.",
|
||||
"lineEditor_nothingSelected": "옮기거나 지울 지점을 선택하거나, Alt를 누른 상태로 클릭해 새 지점을 만드세요",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "미리보기를 볼 수 없습니다",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "너비"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "스타일 복사.",
|
||||
"copyToClipboard": "클립보드로 복사.",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+61
-4
@@ -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": "Ar roku zīmēts",
|
||||
"handDrawn": "Rokraksts",
|
||||
"normal": "Parasts",
|
||||
"code": "Kods",
|
||||
"small": "Mazs",
|
||||
@@ -100,7 +100,9 @@
|
||||
"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"
|
||||
"toggleTheme": "Pārslēgt krāsu tēmu",
|
||||
"personalLib": "Personīgā bibliotēka",
|
||||
"excalidrawLib": "Excalidraw bibliotēka"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Atiestatīt tāfeli",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen režīms",
|
||||
"exitZenMode": "Pamest Zen režīmu",
|
||||
"cancel": "Atcelt",
|
||||
"clear": "Notīrīt"
|
||||
"clear": "Notīrīt",
|
||||
"remove": "Noņemt",
|
||||
"publishLibrary": "Publicēt",
|
||||
"submit": "Iesniegt",
|
||||
"confirm": "Apstiprināt"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Šī funkcija notīrīs visu tāfeli. Vai turpināt?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klikšķiniet, lai novietotu attēlu, vai spiediet un velciet, lai iestatītu tā izmēru",
|
||||
"publishLibrary": "Publicēt savu bibliotēku"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Nevar rādīt priekšskatījumu",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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ā"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Platums"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Pievienots bibliotēkai",
|
||||
"copyStyles": "Nokopēja stilus.",
|
||||
"copyToClipboard": "Nokopēja starpliktuvē.",
|
||||
"copyToClipboardAsPng": "Nokopēja {{exportSelection}} starpliktuvē kā PNG ({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": ""
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "ကားချပ်ရှင်းလင်း",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "",
|
||||
"exitZenMode": "ဇင်မြင်ကွင်းမှထွက်",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "ကားချပ်တစ်ခုလုံးရှင်းလင်းပါတော့မည်။ အတည်ပြုပါ။",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "ဤပုံဖြင့်မြင်ကွင်းပြန်လည်မရယူနိုင်ပါ။",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "အမှတ်များပြင်ဆင်သတ်မှတ်ရင် ကလစ်နှစ်ချက် (သို့) Enter ကိုနှိပ်ပါ",
|
||||
"lineEditor_pointSelected": "အမှတ်များအား ဖျက်ရန် Delete နှင့် ပွားရန် Ctrl/Cmd + D သုံးပါ၊ ရွှေ့လိုပါက တရွတ်ဆွဲပါ",
|
||||
"lineEditor_nothingSelected": "ရွှေ့လို (သို့) ဖယ်ရှားလိုသောအမှတ်ကိုရွေးပါ၊ Alt နှင့် ကလစ်တွဲနှိပ်၍လည်းအမှတ်အသစ်ထပ်ထည့်နိုင်သည်",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "နမူနာမပြသနိုင်ပါ",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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": ""
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "အကျယ်"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Del",
|
||||
"showStroke": "Vis fargevelger for kantfarge",
|
||||
"showBackground": "Vis fargevelger for bakgrunnsfarge",
|
||||
"toggleTheme": "Veksle tema"
|
||||
"toggleTheme": "Veksle tema",
|
||||
"personalLib": "Personlig bibliotek",
|
||||
"excalidrawLib": "Excalidraw-bibliotek"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Tøm lerretet og tilbakestill bakgrunnsfargen",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen-modus",
|
||||
"exitZenMode": "Avslutt zen-modus",
|
||||
"cancel": "Avbryt",
|
||||
"clear": "Tøm"
|
||||
"clear": "Tøm",
|
||||
"remove": "Fjern",
|
||||
"publishLibrary": "Publiser",
|
||||
"submit": "Send inn",
|
||||
"confirm": "Bekreft"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Dette vil tømme lerretet. Er du sikker?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klikk for å plassere bildet, eller klikk og dra for å angi størrelsen manuelt",
|
||||
"publishLibrary": "Publiser ditt eget bibliotek"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Kan ikke vise forhåndsvisning",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Bredde"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Lagt til i biblioteket",
|
||||
"copyStyles": "Kopierte stiler.",
|
||||
"copyToClipboard": "Kopiert til utklippstavlen.",
|
||||
"copyToClipboardAsPng": "Kopierte {{exportSelection}} til utklippstavlen som PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Deel",
|
||||
"showStroke": "Toon lijn kleur kiezer",
|
||||
"showBackground": "Toon achtergrondkleur kiezer",
|
||||
"toggleTheme": "Thema aan/uit"
|
||||
"toggleTheme": "Thema aan/uit",
|
||||
"personalLib": "Persoonlijke bibliotheek",
|
||||
"excalidrawLib": "Excalidraw bibliotheek"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Canvas opnieuw instellen",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen modus",
|
||||
"exitZenMode": "Verlaat zen modus",
|
||||
"cancel": "Annuleren",
|
||||
"clear": "Wissen"
|
||||
"clear": "Wissen",
|
||||
"remove": "Verwijderen",
|
||||
"publishLibrary": "Publiceren",
|
||||
"submit": "Versturen",
|
||||
"confirm": "Bevestigen"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Dit zal het hele canvas verwijderen. Weet je het zeker?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": "Publiceer je eigen bibliotheek"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Kan voorbeeld niet tonen",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Breedte"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Stijlen gekopieerd.",
|
||||
"copyToClipboard": "Gekopieerd naar het klembord.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} naar klembord gekopieerd als PNG\n({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "Del",
|
||||
"showStroke": "Vis fargeveljar for linjer",
|
||||
"showBackground": "Vis fargeveljar for bakgrunn",
|
||||
"toggleTheme": "Veksle tema"
|
||||
"toggleTheme": "Veksle tema",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Tilbakestill lerretet",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Zen-modus",
|
||||
"exitZenMode": "Avslutt zen-modus",
|
||||
"cancel": "Avbryt",
|
||||
"clear": "Tøm"
|
||||
"clear": "Tøm",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Dette vil tømme lerretet. Er du sikker?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"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": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"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"
|
||||
"placeImage": "Klikk for å plassere biletet, eller klikk og drag for å velje storleik manuelt",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Kan ikkje vise førehandsvising",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Breidde"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "Kopierte stilane.",
|
||||
"copyToClipboard": "Kopiert til utklippstavla.",
|
||||
"copyToClipboardAsPng": "Kopierte {{exportSelection}} til utklippstavla som PNG\n({{exportColorScheme}})",
|
||||
|
||||
+64
-7
@@ -35,7 +35,7 @@
|
||||
"arrowhead_arrow": "Sageta",
|
||||
"arrowhead_bar": "Barra",
|
||||
"arrowhead_dot": "Ponch",
|
||||
"arrowhead_triangle": "",
|
||||
"arrowhead_triangle": "Triangle",
|
||||
"fontSize": "Talha poliça",
|
||||
"fontFamily": "Familha de poliça",
|
||||
"onlySelected": "Seleccion sonque",
|
||||
@@ -100,7 +100,9 @@
|
||||
"share": "Partejar",
|
||||
"showStroke": "Mostrar lo selector de color de contorn",
|
||||
"showBackground": "Mostrar lo selector de color de fons",
|
||||
"toggleTheme": "Alternar tèma"
|
||||
"toggleTheme": "Alternar tèma",
|
||||
"personalLib": "Bibliotèca personala",
|
||||
"excalidrawLib": "Bibliotèca Excalidraw"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reïnicializar lo canabàs",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "Mòde escur",
|
||||
"exitZenMode": "Sortir del mòde zen",
|
||||
"cancel": "Anullar",
|
||||
"clear": "Escafar"
|
||||
"clear": "Escafar",
|
||||
"remove": "Tirar",
|
||||
"publishLibrary": "Publicar",
|
||||
"submit": "Enviar",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Aquò suprimirà lo canabàs complèt. O volètz vertadièrament ?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "Restauracion impossibla de la scèna a partir del fichièr imatge",
|
||||
"invalidSceneUrl": "Importacion impossibla de la scèna a partir de l’URL provesida. Es siá mal formatada o siá conten pas cap de donada JSON Excalidraw valida.",
|
||||
"resetLibrary": "Aquò suprimirà vòstra bibliotèca. O volètz vertadièrament ?",
|
||||
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la bibliotèca ?",
|
||||
"invalidEncryptionKey": "La clau de chiframent deu conténer 22 caractèrs. La collaboracion en dirèct es desactivada."
|
||||
},
|
||||
"errors": {
|
||||
@@ -164,7 +171,7 @@
|
||||
"imageInsertError": "Insercion d’imatge impossibla. Tornatz ensajar mai tard...",
|
||||
"fileTooBig": "Fichièr tròp pesuc. La talha maximala autorizada es {{maxSize}}.",
|
||||
"svgImageInsertError": "Insercion d’imatge SVG impossibla. Las balisas SVG semblan invalidas.",
|
||||
"invalidSVGString": ""
|
||||
"invalidSVGString": "SVG invalid."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Seleccion",
|
||||
@@ -185,7 +192,7 @@
|
||||
"shapes": "Formas"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"canvasPanning": "Per desplaçar los canabasses, tenètz la rodeta de la mirga o la barra d’espaci pendent lo desplaçament",
|
||||
"linearElement": "Clicatz per començar mantun punt, lisatz per una sola linha",
|
||||
"freeDraw": "Clicatz e lisatz, relargatz un còp acabat",
|
||||
"text": "Astúcia : podètz tanben apondre de tèxt en doble clicant ont que siá amb l’aisina de seleccion",
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "Doble-clicatz o quichatz Entrada per modificar los punts",
|
||||
"lineEditor_pointSelected": "Quichatz Suprimir per suprimir lo punt, Ctrl o Cmd+D per lo duplicar, o fasètz lisar per lo desplaçar",
|
||||
"lineEditor_nothingSelected": "Seleccionatz un punt de desplaçar o suprimir, o mantenètz Alt e clicatz per apondre punts novèls",
|
||||
"placeImage": "Clicatz per plaçar l’imatge, o clicatz e lisatz per definir sa talha manualament"
|
||||
"placeImage": "Clicatz per plaçar l’imatge, o clicatz e lisatz per definir sa talha manualament",
|
||||
"publishLibrary": "Publicar vòstra pròpria bibliotèca"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Afichatge impossible de l’apercebut",
|
||||
@@ -267,7 +275,55 @@
|
||||
"zoomToSelection": "Zoomar la seleccion"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": ""
|
||||
"title": "Escafar canabàs"
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Publicar la bibliotèca",
|
||||
"itemName": "Nom de l’element",
|
||||
"authorName": "Nom de l’autor",
|
||||
"githubUsername": "Nom d’utilizaire GitHub",
|
||||
"twitterUsername": "Nom d’utilizaire Twitter",
|
||||
"libraryName": "Nom de la bibliotèca",
|
||||
"libraryDesc": "Descripcion de la bibliotèca",
|
||||
"website": "Site web",
|
||||
"placeholder": {
|
||||
"authorName": "Vòstre nom o nom d’utilizaire",
|
||||
"libraryName": "Nom de vòstra bibliotèca",
|
||||
"libraryDesc": "Descripcion de vòstra bibliotèca per ajudar lo monde a comprendre son utilizacion",
|
||||
"githubHandle": "GitHub handle(opcional), per poder modificar la bibliotèca un còp enviada per repassa",
|
||||
"twitterHandle": "Nom d’utilizaire Twitter (opcional), per saber qual mercejar quand ne parlam sus Twitter",
|
||||
"website": "Ligam cap a vòstre site web personal o endacòm mai (opcional)"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Requerit",
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "Enviatz vòstra bibliotèca per èsser compresa al ",
|
||||
"link": "repertòri public de bibliotèca",
|
||||
"post": "per que los autres l’utilizen dins lor dessenhs."
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "Qualqu’un deu aprovar la bibliotèca manualament per començar. Volgatz legir las ",
|
||||
"link": "linhas directrises",
|
||||
"post": " abans de sometre. Vos farà mestièr un compte GitHub per comunicar e realizar de modificacions se demandadas, mas es pas complètament obligatòri."
|
||||
},
|
||||
"noteLicense": {
|
||||
"pre": "En sometent, acceptatz que la bibliotèca siá publicada sota la ",
|
||||
"link": "Licéncia MIT, ",
|
||||
"post": "que significa en brèu que qual que siá pòt l’utilizar sens cap de restriccion."
|
||||
},
|
||||
"noteItems": "Cada element de bibliotèca deu aver un nom pròpri per èsser filtrable. Los elements de bibliotèca seguentas seràn incluses :",
|
||||
"atleastOneLibItem": "Volgatz seleccionar almens un element de bibliotèca per començar"
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Bibliotèca somesa",
|
||||
"content": "Mercés {{authorName}}. Vòstre bibliotèca es estada somesa per repassa. Podètz seguir l’avançament",
|
||||
"link": "aquí"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "Reïnicializar la bibliotèca",
|
||||
"removeItemsFromLib": "Tirar los elements seleccionats de la bibliotèca"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Vòstres dessenhs son chifrats del cap a la fin en consequéncia los servidors d’Excalidraw los veiràn pas jamai.",
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "Largor"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Apondut a la bibliotèca",
|
||||
"copyStyles": "Estiles copiats.",
|
||||
"copyToClipboard": "Copiats al quichapapièrs.",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} copiat coma PNG ({{exportColorScheme}})",
|
||||
|
||||
+60
-3
@@ -100,7 +100,9 @@
|
||||
"share": "ਸਾਂਝਾ ਕਰੋ",
|
||||
"showStroke": "ਰੇਖਾ ਦਾ ਰੰਗ ਚੋਣਕਾਰ ਦਿਖਾਓ",
|
||||
"showBackground": "ਬੈਕਗਰਾਉਂਡ ਦਾ ਰੰਗ ਚੋਣਕਾਰ ਦਿਖਾਓ",
|
||||
"toggleTheme": "ਥੀਮ ਬਦਲੋ"
|
||||
"toggleTheme": "ਥੀਮ ਬਦਲੋ",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "ਕੈਨਵਸ ਰੀਸੈੱਟ ਕਰੋ",
|
||||
@@ -135,7 +137,11 @@
|
||||
"zenMode": "ਜ਼ੈੱਨ ਮੋਡ",
|
||||
"exitZenMode": "ਜ਼ੈੱਨ ਮੋਡ 'ਚੋਂ ਬਾਹਰ ਨਿਕਲੋ",
|
||||
"cancel": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "ਇਹ ਸਾਰਾ ਕੈਨਵਸ ਸਾਫ ਕਰ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
|
||||
@@ -157,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "ਇਸ ਤਸਵੀਰ ਫਾਈਲ ਤੋਂ ਦ੍ਰਿਸ਼ ਬਹਾਲ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ",
|
||||
"invalidSceneUrl": "ਦਿੱਤੀ ਗਈ URL 'ਚੋਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਨਹੀਂ ਕਰ ਸਕੇ। ਇਹ ਜਾਂ ਤਾਂ ਖਰਾਬ ਹੈ, ਜਾਂ ਇਸ ਵਿੱਚ ਜਾਇਜ਼ Excalidraw JSON ਡਾਟਾ ਸ਼ਾਮਲ ਨਹੀਂ ਹੈ।",
|
||||
"resetLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਾਫ ਕਰ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -199,7 +206,8 @@
|
||||
"lineEditor_info": "ਬਿੰਦੂਆਂ ਨੂੰ ਸੋਧਣ ਲਈ ਡਬਲ-ਕਲਿੱਕ ਜਾਂ ਐਂਟਰ ਦਬਾਓ",
|
||||
"lineEditor_pointSelected": "ਬਿੰਦੀ ਹਟਾਉਣ ਲਈ ਡਲੀਟ ਦਬਾਓ, ਡੁਪਲੀਕੇਟ ਬਣਾਉਣ ਲਈ CtrlOrCmd+D, ਜਾਂ ਹਿਲਾਉਣ ਲਈ ਘਸੀਟੋ",
|
||||
"lineEditor_nothingSelected": "ਹਿਲਾਉਣ ਜਾਂ ਹਟਾਉਣ ਲਈ ਬਿੰਦੂ ਚੁਣੋ, ਜਾਂ ਨਵਾਂ ਬਿੰਦੂ ਜੋੜਨ ਲਈ Alt ਦਬਾਕੇ ਕਲਿੱਕ ਕਰੋ",
|
||||
"placeImage": ""
|
||||
"placeImage": "",
|
||||
"publishLibrary": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "ਝਲਕ ਨਹੀਂ ਦਿਖਾ ਸਕਦੇ",
|
||||
@@ -269,6 +277,54 @@
|
||||
"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 ਵਿੱਚ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕ੍ਰਿਪਸ਼ਨ 'ਤੇ ਬਲੌਗ ਸੰਪਾਦਨਾ"
|
||||
@@ -289,6 +345,7 @@
|
||||
"width": "ਚੌੜਾਈ"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "ਕਾਪੀ ਕੀਤੇ ਸਟਾਇਲ।",
|
||||
"copyToClipboard": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤਾ।",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} ਨੂੰ ਕਲਿੱਪਬੋਰਡ 'ਤੇ PNG ਵਜੋਂ ਕਾਪੀ ਕੀਤਾ ({{exportColorScheme}})",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user