Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-02-27 14:18:41 -06:00
commit 4c939cefad
85 changed files with 902 additions and 820 deletions

View File

@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com ## Excalidraw.com
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features: The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
- 📡 PWA support (works offline). - 📡 PWA support (works offline).
- 🤼 Real-time collaboration. - 🤼 Real-time collaboration.

View File

@ -37,10 +37,9 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const { width, height, baseline } = measureTextElement( const { width, height } = measureTextElement(boundTextElement, {
boundTextElement, text: boundTextElement.originalText,
{ text: boundTextElement.originalText }, });
);
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
@ -50,7 +49,6 @@ export const actionUnbindText = register({
containerId: null, containerId: null,
width, width,
height, height,
baseline,
text: boundTextElement.originalText, text: boundTextElement.originalText,
}); });
mutateElement(element, { mutateElement(element, {

View File

@ -109,6 +109,7 @@ import {
textWysiwyg, textWysiwyg,
transformElements, transformElements,
updateTextElement, updateTextElement,
redrawTextBoundingBox,
} from "../element"; } from "../element";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
@ -276,7 +277,6 @@ import {
getContainerElement, getContainerElement,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isValidTextContainer, isValidTextContainer,
redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import { import {
@ -1688,6 +1688,7 @@ class App extends React.Component<AppProps, AppState> {
oldIdToDuplicatedId.set(element.id, newElement.id); oldIdToDuplicatedId.set(element.id, newElement.id);
return newElement; return newElement;
}); });
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId); bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [ const nextElements = [
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
@ -1700,6 +1701,14 @@ class App extends React.Component<AppProps, AppState> {
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(newElement);
redrawTextBoundingBox(newElement, container);
}
});
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(
@ -2728,14 +2737,6 @@ class App extends React.Component<AppProps, AppState> {
element, element,
]); ]);
} }
// case: creating new text not centered to parent element → offset Y
// so that the text is centered to cursor position
if (!parentCenterPosition) {
mutateElement(element, {
y: element.y - element.baseline / 2,
});
}
} }
this.setState({ this.setState({

View File

@ -1,5 +1,5 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { import {
useExcalidrawAppState, useExcalidrawAppState,
useExcalidrawSetAppState, useExcalidrawSetAppState,
@ -33,9 +33,7 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
export const LoadScene = () => { export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionLoadScene)) { if (!actionManager.isActionEnabled(actionLoadScene)) {
@ -57,9 +55,7 @@ export const LoadScene = () => {
LoadScene.displayName = "LoadScene"; LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => { export const SaveToActiveFile = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) { if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
@ -80,9 +76,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => { export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
icon={ExportImageIcon} icon={ExportImageIcon}
@ -98,9 +92,7 @@ export const SaveAsImage = () => {
SaveAsImage.displayName = "SaveAsImage"; SaveAsImage.displayName = "SaveAsImage";
export const Help = () => { export const Help = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -119,9 +111,8 @@ export const Help = () => {
Help.displayName = "Help"; Help.displayName = "Help";
export const ClearCanvas = () => { export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -143,6 +134,7 @@ export const ClearCanvas = () => {
ClearCanvas.displayName = "ClearCanvas"; ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => { export const ToggleTheme = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -175,6 +167,7 @@ export const ToggleTheme = () => {
ToggleTheme.displayName = "ToggleTheme"; ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => { export const ChangeCanvasBackground = () => {
const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@ -195,9 +188,7 @@ export const ChangeCanvasBackground = () => {
ChangeCanvasBackground.displayName = "ChangeCanvasBackground"; ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => { export const Export = () => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
@ -248,9 +239,7 @@ export const LiveCollaborationTrigger = ({
onSelect: () => void; onSelect: () => void;
isCollaborating: boolean; isCollaborating: boolean;
}) => { }) => {
// FIXME Hack until we tie "t" to lang state const { t } = useI18n();
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return ( return (
<DropdownMenuItem <DropdownMenuItem
data-testid="collab-button" data-testid="collab-button"

View File

@ -1,6 +1,6 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { import {
useDevice, useDevice,
useExcalidrawActionManager, useExcalidrawActionManager,
@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
}: { }: {
onSelect: () => any; onSelect: () => any;
}) => { }) => {
// FIXME when we tie t() to lang state const { t } = useI18n();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const appState = useExcalidrawAppState();
return ( return (
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}> <WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
{t("labels.liveCollaboration")} {t("labels.liveCollaboration")}

View File

@ -9,6 +9,9 @@ export const isFirefox =
"netscape" in window && "netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1; navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";

View File

@ -530,6 +530,7 @@
// (doesn't work in Firefox) // (doesn't work in Firefox)
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 3px; width: 3px;
height: 3px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@ -567,8 +568,8 @@
} }
.App-toolbar--mobile { .App-toolbar--mobile {
overflow-x: hidden; overflow-x: auto;
max-width: 100vw; max-width: 90vw;
.ToolIcon__keybinding { .ToolIcon__keybinding {
display: none; display: none;

View File

@ -176,7 +176,6 @@ const restoreElement = (
fontSize, fontSize,
fontFamily, fontFamily,
text: element.text ?? "", text: element.text ?? "",
baseline: element.baseline,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,

View File

@ -22,15 +22,15 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getBoundTextElement,
getBoundTextElementOffset, getBoundTextElementOffset,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
measureTextElement, measureTextElement,
normalizeText, normalizeText,
wrapTextElement, wrapTextElement,
getMaxContainerWidth,
} from "./textElement"; } from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
import { getSubtypeMethods, isValidSubtype } from "../subtypes"; import { getSubtypeMethods, isValidSubtype } from "../subtypes";
@ -189,7 +189,6 @@ export const newTextElement = (
y: opts.y - offsets.y, y: opts.y - offsets.y,
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
}, },
@ -206,18 +205,12 @@ const getAdjustedDimensions = (
y: number; y: number;
width: number; width: number;
height: number; height: number;
baseline: number;
} => { } => {
let maxWidth = null;
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) {
maxWidth = getMaxContainerWidth(container); const { width: nextWidth, height: nextHeight } = measureTextElement(element, {
} text: nextText,
const { });
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureTextElement(element, { text: nextText }, maxWidth);
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
@ -226,11 +219,9 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId
) { ) {
const prevMetrics = measureTextElement( const prevMetrics = measureTextElement(element, {
element, fontSize: element.fontSize,
{ fontSize: element.fontSize }, });
maxWidth,
);
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,
@ -294,7 +285,6 @@ const getAdjustedDimensions = (
height: nextHeight, height: nextHeight,
x: Number.isFinite(x) ? x : element.x, x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y, y: Number.isFinite(y) ? y : element.y,
baseline: nextBaseline,
}; };
}; };
@ -312,38 +302,6 @@ export const refreshTextDimensions = (
return { text, ...dimensions }; return { text, ...dimensions };
}; };
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = ( export const updateTextElement = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
{ {

View File

@ -43,12 +43,10 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
measureTextElement, getMaxContainerWidth,
} from "./textElement"; } from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
@ -192,11 +190,10 @@ const rescalePointsInElement = (
const MIN_FONT_SIZE = 1; const MIN_FONT_SIZE = 1;
const measureFontSizeFromWH = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
nextHeight: number, ): number | null => {
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize // We only use width to scale font on resize
let width = element.width; let width = element.width;
@ -211,15 +208,8 @@ const measureFontSizeFromWH = (
if (nextFontSize < MIN_FONT_SIZE) { if (nextFontSize < MIN_FONT_SIZE) {
return null; return null;
} }
const metrics = measureTextElement(
element, return nextFontSize;
{ fontSize: nextFontSize },
element.containerId ? width : null,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
}; };
const getSidesForTransformHandle = ( const getSidesForTransformHandle = (
@ -290,8 +280,8 @@ const resizeSingleTextElement = (
if (scale > 0) { if (scale > 0) {
const nextWidth = element.width * scale; const nextWidth = element.width * scale;
const nextHeight = element.height * scale; const nextHeight = element.height * scale;
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
if (nextFont === null) { if (nextFontSize === null) {
return; return;
} }
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@ -315,10 +305,9 @@ const resizeSingleTextElement = (
deltaY2, deltaY2,
); );
mutateElement(element, { mutateElement(element, {
fontSize: nextFont.size, fontSize: nextFontSize,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextFont.baseline,
x: nextElementX, x: nextElementX,
y: nextElementY, y: nextElementY,
}); });
@ -371,7 +360,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight; let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number; baseline?: number } = {}; let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
@ -423,23 +412,24 @@ export const resizeSingleElement = (
if (stateOfBoundTextElementAtResize) { if (stateOfBoundTextElementAtResize) {
boundTextFont = { boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize, fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
}; };
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const boundTextElementPadding = const updatedElement = {
getBoundTextElementOffset(boundTextElement); ...element,
const nextFont = measureFontSizeFromWH( width: eleNewWidth,
height: eleNewHeight,
};
const nextFontSize = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
eleNewWidth - boundTextElementPadding * 2, getMaxContainerWidth(updatedElement),
eleNewHeight - boundTextElementPadding * 2,
); );
if (nextFont === null) { if (nextFontSize === null) {
return; return;
} }
boundTextFont = { boundTextFont = {
fontSize: nextFont.size, fontSize: nextFontSize,
baseline: nextFont.baseline,
}; };
} else { } else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
@ -683,7 +673,6 @@ const resizeMultipleElements = (
y: number; y: number;
points?: Point[]; points?: Point[];
fontSize?: number; fontSize?: number;
baseline?: number;
} = { } = {
width, width,
height, height,
@ -692,31 +681,32 @@ const resizeMultipleElements = (
...rescaledPoints, ...rescaledPoints,
}; };
let boundTextUpdates: { fontSize: number; baseline: number } | null = null; let boundTextUpdates: { fontSize: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest); const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) { if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2; const updatedElement = {
const textMeasurements = measureFontSizeFromWH( ...element.latest,
width,
height,
};
const fontSize = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding, getMaxContainerWidth(updatedElement),
height - optionalPadding,
); );
if (!textMeasurements) { if (!fontSize) {
return; return;
} }
if (isTextElement(element.orig)) { if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size; update.fontSize = fontSize;
update.baseline = textMeasurements.baseline;
} }
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { boundTextUpdates = {
fontSize: textMeasurements.size, fontSize,
baseline: textMeasurements.baseline,
}; };
} }
} }

View File

@ -1,5 +1,12 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement"; import { API } from "../tests/helpers/api";
import {
computeContainerHeightForBoundText,
getContainerCoords,
getMaxContainerWidth,
getMaxContainerHeight,
wrapText,
} from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
@ -65,6 +72,13 @@ up`,
width: 250, width: 250,
res: "Hello whats up", res: "Hello whats up",
}, },
{
desc: "should push the word if its equal to max width",
width: 60,
res: `Hello
whats
up`,
},
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
@ -72,6 +86,7 @@ up`,
}); });
}); });
}); });
describe("When text contain new lines", () => { describe("When text contain new lines", () => {
const text = `Hello const text = `Hello
whats up`; whats up`;
@ -162,35 +177,115 @@ break it now`,
}); });
describe("Test measureText", () => { describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; describe("Test getContainerCoords", () => {
const text = "Hello World"; const params = { width: 200, height: 100, x: 10, y: 20 };
it("should add correct attributes when maxWidth is passed", () => { it("should compute coords correctly when ellipse", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2; const element = API.createElement({
const res = measureText(text, font, maxWidth); type: "ellipse",
...params,
expect(res.container).toMatchInlineSnapshot(` });
<div expect(getContainerCoords(element)).toEqual({
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;" x: 44.2893218813452455,
> y: 39.64466094067262,
<span });
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
}); });
it("should add correct attributes when maxWidth is not passed", () => { it("should compute coords correctly when rectangle", () => {
const res = measureText(text, font); const element = API.createElement({
type: "rectangle",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 15,
y: 25,
});
});
expect(res.container).toMatchInlineSnapshot(` it("should compute coords correctly when diamond", () => {
<div const element = API.createElement({
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;" type: "diamond",
> ...params,
<span });
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;" expect(getContainerCoords(element)).toEqual({
/> x: 65,
</div> y: 50,
`); });
});
});
describe("Test computeContainerHeightForBoundText", () => {
const params = {
width: 178,
height: 194,
};
it("should compute container height correctly for rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(160);
});
it("should compute container height correctly for ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(226);
});
it("should compute container height correctly for diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(computeContainerHeightForBoundText(element, 150)).toEqual(320);
});
});
describe("Test getMaxContainerWidth", () => {
const params = {
width: 178,
height: 194,
};
it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerWidth(container)).toBe(168);
});
it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerWidth(container)).toBe(116);
});
it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerWidth(container)).toBe(79);
});
});
describe("Test getMaxContainerHeight", () => {
const params = {
width: 178,
height: 194,
};
it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getMaxContainerHeight(container)).toBe(184);
});
it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getMaxContainerHeight(container)).toBe(127);
});
it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getMaxContainerHeight(container)).toBe(87);
});
}); });
}); });

View File

@ -13,7 +13,6 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles"; import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isTextElement } from "."; import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { import {
isBoundToContainer, isBoundToContainer,
isImageElement, isImageElement,
@ -30,16 +29,16 @@ import {
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./textWysiwyg"; } from "./textWysiwyg";
export const measureTextElement = function (element, next, maxWidth) { export const measureTextElement = function (element, next) {
const map = getSubtypeMethods(element.subtype); const map = getSubtypeMethods(element.subtype);
if (map?.measureText) { if (map?.measureText) {
return map.measureText(element, next, maxWidth); return map.measureText(element, next);
} }
const fontSize = next?.fontSize ?? element.fontSize; const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily }); const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text; const text = next?.text ?? element.text;
return measureText(text, font, maxWidth); return measureText(text, font);
} as SubtypeMethods["measureText"]; } as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) { export const wrapTextElement = function (element, containerWidth, next) {
@ -69,78 +68,69 @@ export const redrawTextBoundingBox = (
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
let text = textElement.text;
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
};
boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getMaxContainerWidth(container);
text = wrapTextElement(textElement, maxWidth); boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
} }
const width = measureTextElement( const metrics = measureTextElement(textElement, {
textElement, text: boundTextUpdates.text,
{ text: textElement.originalText }, });
maxWidth,
).width; boundTextUpdates.width = metrics.width;
const { height, baseline } = measureTextElement(textElement, { text }); boundTextUpdates.height = metrics.height;
const metrics = { width, height, baseline };
let coordY = textElement.y;
let coordX = textElement.x;
// Maintain coordX for non left-aligned text in case the width has changed // Maintain coordX for non left-aligned text in case the width has changed
if (!container) { if (!container) {
if (textElement.textAlign === TEXT_ALIGN.RIGHT) { if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX += textElement.width - metrics.width; boundTextUpdates.x += textElement.width - metrics.width;
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) { } else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
coordX += textElement.width / 2 - metrics.width / 2; boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
} }
} }
// Resize container and vertically center align the text
if (container) { if (container) {
if (!isArrowElement(container)) { if (isArrowElement(container)) {
const containerDims = getContainerDims(container);
let nextHeight = containerDims.height;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
container.y +
containerDims.height -
metrics.height -
BOUND_TEXT_PADDING;
} else {
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
}
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + BOUND_TEXT_PADDING;
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX =
container.x +
containerDims.width -
metrics.width -
BOUND_TEXT_PADDING;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
} else {
const centerX = textElement.x + textElement.width / 2; const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2; const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width; const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height; const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2; boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2; boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
} else {
const containerDims = getContainerDims(container);
let maxContainerHeight = getMaxContainerHeight(container);
let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerHeightForBoundText(
container,
metrics.height,
);
mutateElement(container, { height: nextHeight });
maxContainerHeight = getMaxContainerHeight(container);
updateOriginalContainerCache(container.id, nextHeight);
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
} }
} }
mutateElement(textElement, {
width: metrics.width, mutateElement(textElement, boundTextUpdates);
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
}; };
export const bindTextToShapeAfterDuplication = ( export const bindTextToShapeAfterDuplication = (
@ -212,23 +202,21 @@ export const handleBindTextResize = (
const maxWidth = getMaxContainerWidth(container); const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container); const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height; let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") { if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) { if (text) {
text = wrapTextElement(textElement, maxWidth); text = wrapTextElement(textElement, maxWidth);
} }
const dimensions = measureTextElement( const dimensions = measureTextElement(textElement, { text });
textElement,
{ text },
container.width,
);
nextHeight = dimensions.height; nextHeight = dimensions.height;
nextWidth = dimensions.width; nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
} }
// increase height in case text element height exceeds // increase height in case text element height exceeds
if (nextHeight > maxHeight) { if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2; containerHeight = computeContainerHeightForBoundText(
container,
nextHeight,
);
const diff = containerHeight - containerDims.height; const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n // fix the y coord when resizing from ne/nw/n
const updatedY = const updatedY =
@ -248,94 +236,64 @@ export const handleBindTextResize = (
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseLine,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
updateBoundTextPosition( mutateElement(
textElement,
computeBoundTextPosition(
container, container,
textElement as ExcalidrawTextElementWithContainer, textElement as ExcalidrawTextElementWithContainer,
),
); );
} }
} }
}; };
const updateBoundTextPosition = ( const computeBoundTextPosition = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const containerDims = getContainerDims(container); const containerCoords = getContainerCoords(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); const maxContainerHeight = getMaxContainerHeight(container);
const maxContainerWidth = getMaxContainerWidth(container);
let x;
let y; let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding; y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else { } else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2; y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2);
} }
const x = if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
boundTextElement.textAlign === TEXT_ALIGN.LEFT x = containerCoords.x;
? container.x + boundTextElementPadding } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
? container.x + } else {
containerDims.width - x =
boundTextElement.width - containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
boundTextElementPadding }
: container.x + containerDims.width / 2 - boundTextElement.width / 2; return { x, y };
mutateElement(boundTextElement, { x, y });
}; };
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string, export const measureText = (text: string, font: FontString) => {
font: FontString,
maxWidth?: number | null,
) => {
text = text text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .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 height = getTextHeight(text, font);
const lineHeight = getApproxLineHeight(font); const width = getTextWidth(text, font);
// since we are adding a span of width 1px later
container.style.maxWidth = `${maxWidth + 1}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"); return { width, height };
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;
// since we are adding a span of width 1px
const width = container.offsetWidth + 1;
const height = container.offsetHeight;
document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline };
}; };
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
@ -345,40 +303,47 @@ export const getApproxLineHeight = (font: FontString) => {
if (cacheApproxLineHeight[font]) { if (cacheApproxLineHeight[font]) {
return cacheApproxLineHeight[font]; return cacheApproxLineHeight[font];
} }
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height; const fontSize = parseInt(font);
// Calculate line height relative to font size
cacheApproxLineHeight[font] = fontSize * 1.2;
return cacheApproxLineHeight[font]; return cacheApproxLineHeight[font];
}; };
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
const getLineWidth = (text: string, font: FontString) => { const getLineWidth = (text: string, font: FontString) => {
if (!canvas) { if (!canvas) {
canvas = document.createElement("canvas"); canvas = document.createElement("canvas");
} }
const canvas2dContext = canvas.getContext("2d")!; const canvas2dContext = canvas.getContext("2d")!;
canvas2dContext.font = font; canvas2dContext.font = font;
const width = canvas2dContext.measureText(text).width;
const metrics = canvas2dContext.measureText(text);
// since in test env the canvas measureText algo // since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of // doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px // characters hence we assume that each letteris 10px
if (isTestEnv()) { if (isTestEnv()) {
return metrics.width * 10; return width * 10;
} }
// Since measureText behaves differently in different browsers return width;
// OS so considering a adjustment factor of 0.2
const adjustmentFactor = 0.2;
return metrics.width + adjustmentFactor;
}; };
export const getTextWidth = (text: string, font: FontString) => { export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n"); const lines = text.replace(/\r\n?/g, "\n").split("\n");
let width = 0; let width = 0;
lines.forEach((line) => { lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font)); width = Math.max(width, getLineWidth(line, font));
}); });
return width; return width;
}; };
export const getTextHeight = (text: string, font: FontString) => {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = getApproxLineHeight(font);
return lineHeight * lines.length;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => { export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
@ -400,16 +365,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let currentLineWidthTillNow = 0; let currentLineWidthTillNow = 0;
let index = 0; let index = 0;
while (index < words.length) { while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font); const currentWordWidth = getLineWidth(words[index], font);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width // Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) { else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width // push current line since the current word exceeds the max width
// so will be appended in next line // so will be appended in next line
push(currentLine); push(currentLine);
currentLine = ""; currentLine = "";
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
while (words[index].length > 0) { while (words[index].length > 0) {
const currentChar = String.fromCodePoint( const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!, words[index].codePointAt(0)!,
@ -510,9 +482,9 @@ export const charWidth = (() => {
getCache, getCache,
}; };
})(); })();
export const getApproxMinLineWidth = (font: FontString) => { export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font); const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width + measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@ -652,6 +624,26 @@ export const getContainerCenter = (
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
}; };
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
}
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
if (container.type === "diamond") {
offsetX += container.width / 4;
offsetY += container.height / 4;
}
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) { if (!container || isArrowElement(container)) {
@ -664,12 +656,13 @@ export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null, boundTextElement: ExcalidrawTextElement | null,
) => { ) => {
const container = getContainerElement(boundTextElement); const container = getContainerElement(boundTextElement);
if (!container) { if (!container || !boundTextElement) {
return 0; return 0;
} }
if (isArrowElement(container)) { if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8; return BOUND_TEXT_PADDING * 8;
} }
return BOUND_TEXT_PADDING; return BOUND_TEXT_PADDING;
}; };
@ -754,3 +747,76 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
isArrowElement(element) isArrowElement(element)
); );
}; };
export const computeContainerHeightForBoundText = (
container: NonDeletedExcalidrawElement,
boundTextElementHeight: number,
) => {
if (container.type === "ellipse") {
return Math.round(
((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2,
);
}
if (isArrowElement(container)) {
return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
}
if (container.type === "diamond") {
return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2);
}
return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
}
return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
if (container.type === "ellipse") {
// The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
}
return height - BOUND_TEXT_PADDING * 2;
};

View File

@ -6,14 +6,11 @@ import { CODES, KEYS } from "../keys";
import { fireEvent } from "../tests/test-utils"; import { fireEvent } from "../tests/test-utils";
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; import { FONT_FAMILY } from "../constants";
import { import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString,
} from "./types"; } from "./types";
import * as textElementUtils from "./textElement";
import { getFontString } from "../utils";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils"; import { resize } from "../tests/utils";
@ -442,17 +439,6 @@ describe("textWysiwyg", () => {
let rectangle: any; let rectangle: any;
const { h } = window; const { h } = window;
const DUMMY_HEIGHT = 240;
const DUMMY_WIDTH = 160;
const APPROX_LINE_HEIGHT = 25;
const INITIAL_WIDTH = 10;
beforeAll(() => {
jest
.spyOn(textElementUtils, "getApproxLineHeight")
.mockReturnValue(APPROX_LINE_HEIGHT);
});
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
h.elements = []; h.elements = [];
@ -734,53 +720,6 @@ describe("textWysiwyg", () => {
}); });
it("should wrap text and vertcially center align once text submitted", async () => { it("should wrap text and vertcially center align once text submitted", async () => {
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
Keyboard.keyDown(KEYS.ENTER); Keyboard.keyDown(KEYS.ENTER);
@ -789,11 +728,6 @@ describe("textWysiwyg", () => {
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
fireEvent.change(editor, { fireEvent.change(editor, {
target: { target: {
value: "Hello World!", value: "Hello World!",
@ -808,11 +742,11 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello \nWorld!"); expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!"); expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(25);
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); expect(text.height).toBe(48);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); expect(text.width).toBe(60);
// Edit and text by removing second line and it should // Edit and text by removing second line and it should
// still vertically align correctly // still vertically align correctly
@ -829,11 +763,6 @@ describe("textWysiwyg", () => {
}, },
}); });
// mock scroll height
jest
.spyOn(editor, "scrollHeight", "get")
.mockImplementation(() => APPROX_LINE_HEIGHT);
editor.style.height = "25px";
editor.dispatchEvent(new Event("input")); editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -843,12 +772,12 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello"); expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello"); expect(text.originalText).toBe("Hello");
expect(text.height).toBe(24);
expect(text.width).toBe(50);
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(30);
expect(text.height).toBe(APPROX_LINE_HEIGHT);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
}); });
it("should unbind bound text when unbind action from context menu is triggered", async () => { it("should unbind bound text when unbind action from context menu is triggered", async () => {
@ -935,8 +864,8 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
109.5, 85,
17, 5,
] ]
`); `);
@ -950,6 +879,8 @@ describe("textWysiwyg", () => {
editor.select(); editor.select();
fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -960,7 +891,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
90, 66,
] ]
`); `);
@ -983,7 +914,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
424, 375,
-539, -539,
] ]
`); `);
@ -1098,9 +1029,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80); expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85); expect(rectangle.y).toBe(-35);
expect(text.x).toBe(89.5); expect(text.x).toBe(85);
expect(text.y).toBe(90); expect(text.y).toBe(-30);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
@ -1130,43 +1061,6 @@ describe("textWysiwyg", () => {
}); });
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
const originalRectHeight = rectangle.height; const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight); expect(rectangle.height).toBe(originalRectHeight);
@ -1180,7 +1074,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" }, target: { value: "Online whiteboard collaboration made easy" },
}); });
editor.blur(); editor.blur();
expect(rectangle.height).toBe(135); expect(rectangle.height).toBe(178);
mouse.select(rectangle); mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
@ -1206,7 +1100,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215); expect(rectangle.height).toBe(156);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle); mouse.select(rectangle);
@ -1218,13 +1112,12 @@ describe("textWysiwyg", () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
expect(rectangle.height).toBe(215); expect(rectangle.height).toBe(156);
// cache updated again // cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
}); });
//@todo fix this test later once measureText is mocked correctly it("should reset the container height cache when font properties updated", async () => {
it.skip("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
@ -1250,7 +1143,9 @@ describe("textWysiwyg", () => {
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36); ).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
96.39999999999999,
);
}); });
describe("should align correctly", () => { describe("should align correctly", () => {
@ -1278,7 +1173,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
20, 25,
] ]
`); `);
}); });
@ -1288,8 +1183,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align top")); fireEvent.click(screen.getByTitle("Align top"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
94.5, 30,
20, 25,
] ]
`); `);
}); });
@ -1300,8 +1195,8 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
20, 25,
] ]
`); `);
}); });
@ -1312,7 +1207,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
25, 45.5,
] ]
`); `);
}); });
@ -1323,8 +1218,8 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
-25, 30,
25, 45.5,
] ]
`); `);
}); });
@ -1335,8 +1230,8 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
25, 45.5,
] ]
`); `);
}); });
@ -1348,7 +1243,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
25, 66,
] ]
`); `);
}); });
@ -1358,8 +1253,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
94.5, 30,
25, 66,
] ]
`); `);
}); });
@ -1369,8 +1264,8 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Align bottom")); fireEvent.click(screen.getByTitle("Align bottom"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 45,
25, 66,
] ]
`); `);
}); });

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants"; import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -24,14 +24,17 @@ import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset, getContainerCoords,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
measureText,
getTextWidth, getTextWidth,
measureText,
normalizeText, normalizeText,
redrawTextBoundingBox,
wrapText, wrapText,
getMaxContainerHeight,
getMaxContainerWidth,
} from "./textElement"; } from "./textElement";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@ -39,7 +42,6 @@ import {
} from "../actions/actionProperties"; } from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
@ -155,19 +157,23 @@ export const textWysiwyg = ({
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
let eCoordY = coordY;
const container = getContainerElement(updatedTextElement); const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
// Editing metrics // Editing metrics
const eMetrics = measureText( const eMetrics = measureText(
container && updatedTextElement.containerId
? wrapText(
updatedTextElement.originalText, updatedTextElement.originalText,
getFontString(updatedTextElement), getFontString(updatedTextElement),
container ? getContainerDims(container).width : null, getMaxContainerWidth(container),
)
: updatedTextElement.originalText,
getFontString(updatedTextElement),
); );
let maxWidth = eMetrics.width;
let maxHeight = eMetrics.height; let maxHeight = eMetrics.height;
const width = eMetrics.width; let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
// Set to element height by default since that's // Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let textElementHeight = Math.max(updatedTextElement.height, maxHeight); let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
@ -181,7 +187,6 @@ export const textWysiwyg = ({
); );
coordX = boundTextCoords.x; coordX = boundTextCoords.x;
coordY = boundTextCoords.y; coordY = boundTextCoords.y;
eCoordY = coordY;
} }
const propertiesUpdated = textPropertiesUpdated( const propertiesUpdated = textPropertiesUpdated(
updatedTextElement, updatedTextElement,
@ -198,7 +203,11 @@ export const textWysiwyg = ({
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
textElementHeight = textElementHeight =
getApproxLineHeight(font) * getApproxLineHeight(font) *
updatedTextElement.text.split("\n").length; wrapText(
updatedTextElement.originalText,
font,
getMaxContainerWidth(container),
).split("\n").length;
textElementHeight = Math.max( textElementHeight = Math.max(
textElementHeight, textElementHeight,
updatedTextElement.height, updatedTextElement.height,
@ -248,25 +257,21 @@ export const textWysiwyg = ({
// Start pushing text upward until a diff of 30px (padding) // Start pushing text upward until a diff of 30px (padding)
// is reached // is reached
else { else {
const containerCoords = getContainerCoords(container);
// vertically center align the text // vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
coordY = coordY =
container.y + containerDims.height / 2 - textElementHeight / 2; containerCoords.y + maxHeight / 2 - textElementHeight / 2;
eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2;
} }
} }
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY = coordY = containerCoords.y + (maxHeight - textElementHeight);
container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
eCoordY = coordY + textElementHeight - eMetrics.height;
} }
} }
} }
const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY); const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
const initialSelectionStart = editable.selectionStart; const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd; const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length; const initialLength = editable.value.length;
@ -308,6 +313,12 @@ export const textWysiwyg = ({
: 0; : 0;
const { width: w, height: h } = updatedTextElement; const { width: w, height: h } = updatedTextElement;
let transformWidth = updatedTextElement.width;
// As firefox, Safari needs little higher dimensions on DOM
if (isFirefox || isSafari) {
textElementWidth += 0.5;
transformWidth += 0.5;
}
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value; (appState.height - viewportY) / appState.zoom.value;
@ -315,14 +326,14 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`, lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`, width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
transformOrigin: `${w / 2}px ${h / 2}px`, transformOrigin: `${w / 2}px ${h / 2}px`,
transform: getTransform( transform: getTransform(
offsetX, offsetX,
updatedTextElement.width, transformWidth,
updatedTextElement.height, updatedTextElement.height,
getTextElementAngle(updatedTextElement), getTextElementAngle(updatedTextElement),
appState, appState,
@ -415,55 +426,16 @@ export const textWysiwyg = ({
id, id,
) as ExcalidrawTextElement; ) as ExcalidrawTextElement;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
// using scrollHeight here since we need to calculate if (isBoundToContainer(element)) {
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
// doubles the height as soon as user starts typing
if (isBoundToContainer(element) && lines > 1) {
const container = getContainerElement(element); const container = getContainerElement(element);
let height = "auto";
editable.style.height = "0px";
let heightSet = false;
if (lines === 2) {
const actualLineCount = wrapText(
editable.value,
font,
getMaxContainerWidth(container!),
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
// hence reducing the height by half if actual line count is 1
// so single line aligns vertically when deleting
if (actualLineCount === 1) {
height = `${editable.scrollHeight / 2}px`;
editable.style.height = height;
heightSet = true;
}
}
const wrappedText = wrapText( const wrappedText = wrapText(
normalizeText(editable.value), normalizeText(editable.value),
font, font,
getMaxContainerWidth(container!), getMaxContainerWidth(container!),
); );
const width = getTextWidth(wrappedText, font); const { width, height } = measureText(wrappedText, font);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
editable.style.height = `${height}px`;
if (!heightSet) {
editable.style.height = `${editable.scrollHeight}px`;
}
} }
onChange(normalizeText(editable.value)); onChange(normalizeText(editable.value));
}; };
@ -500,7 +472,9 @@ export const textWysiwyg = ({
event.code === CODES.BRACKET_RIGHT)) event.code === CODES.BRACKET_RIGHT))
) { ) {
event.preventDefault(); event.preventDefault();
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { if (event.isComposing) {
return;
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
outdent(); outdent();
} else { } else {
indent(); indent();
@ -649,6 +623,7 @@ export const textWysiwyg = ({
), ),
}); });
} }
redrawTextBoundingBox(updateElement, container);
} }
onSubmit({ onSubmit({

View File

@ -132,7 +132,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number; fontSize: number;
fontFamily: FontFamilyValues; fontFamily: FontFamilyValues;
text: string; text: string;
baseline: number;
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;

View File

@ -0,0 +1,3 @@
import { unstable_createStore } from "jotai";
export const appJotaiStore = unstable_createStore();

View File

@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiStore } from "../../jotai"; import { appJotaiStore } from "../app-jotai";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false); export const collabDialogShownAtom = atom(false);
@ -167,7 +167,7 @@ class Collab extends PureComponent<Props, CollabState> {
setUsername: this.setUsername, setUsername: this.setUsername,
}; };
jotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle(); this.onOfflineStatusToggle();
if ( if (
@ -185,7 +185,7 @@ class Collab extends PureComponent<Props, CollabState> {
} }
onOfflineStatusToggle = () => { onOfflineStatusToggle = () => {
jotaiStore.set(isOfflineAtom, !window.navigator.onLine); appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
}; };
componentWillUnmount() { componentWillUnmount() {
@ -208,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => { private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating); appJotaiStore.set(isCollaboratingAtom, isCollaborating);
}; };
private onUnload = () => { private onUnload = () => {
@ -804,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
); );
handleClose = () => { handleClose = () => {
jotaiStore.set(collabDialogShownAtom, false); appJotaiStore.set(collabDialogShownAtom, false);
}; };
setUsername = (username: string) => { setUsername = (username: string) => {

View File

@ -10,13 +10,13 @@ import {
shareWindows, shareWindows,
} from "../../components/icons"; } from "../../components/icons";
import { ToolButton } from "../../components/ToolButton"; import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n";
import "./RoomDialog.scss"; import "./RoomDialog.scss";
import Stack from "../../components/Stack"; import Stack from "../../components/Stack";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils"; import { getFrame } from "../../utils";
import DialogActionButton from "../../components/DialogActionButton"; import DialogActionButton from "../../components/DialogActionButton";
import { useI18n } from "../../i18n";
const getShareIcon = () => { const getShareIcon = () => {
const navigator = window.navigator as any; const navigator = window.navigator as any;
@ -51,6 +51,7 @@ const RoomDialog = ({
setErrorMessage: (message: string) => void; setErrorMessage: (message: string) => void;
theme: AppState["theme"]; theme: AppState["theme"];
}) => { }) => {
const { t } = useI18n();
const roomLinkInput = useRef<HTMLInputElement>(null); const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => { const copyRoomLink = async () => {

View File

@ -1,12 +1,13 @@
import React from "react"; import React from "react";
import { PlusPromoIcon } from "../../components/icons"; import { PlusPromoIcon } from "../../components/icons";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index"; import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
}> = React.memo((props) => { }> = React.memo((props) => {
const { t } = useI18n();
let headingContent; let headingContent;
if (isExcalidrawPlusSignedUser) { if (isExcalidrawPlusSignedUser) {

View File

@ -1,8 +1,11 @@
import { shield } from "../../components/icons"; import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip"; import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
export const EncryptedIcon = () => ( export const EncryptedIcon = () => {
const { t } = useI18n();
return (
<a <a
className="encrypted-icon tooltip" className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/" href="https://blog.excalidraw.com/end-to-end-encryption/"
@ -14,4 +17,5 @@ export const EncryptedIcon = () => (
{shield} {shield}
</Tooltip> </Tooltip>
</a> </a>
); );
};

View File

@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { FileId, NonDeletedExcalidrawElement } from "../../element/types"; import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { excalidrawPlusIcon } from "./icons"; import { excalidrawPlusIcon } from "./icons";
import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../element/typeChecks";
@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
files: BinaryFiles; files: BinaryFiles;
onError: (error: Error) => void; onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => { }> = ({ elements, appState, files, onError }) => {
const { t } = useI18n();
return ( return (
<Card color="primary"> <Card color="primary">
<div className="Card-icon">{excalidrawPlusIcon}</div> <div className="Card-icon">{excalidrawPlusIcon}</div>

View File

@ -1,22 +1,23 @@
import { useAtom } from "jotai"; import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { langCodeAtom } from ".."; import { appLangCodeAtom } from "..";
import * as i18n from "../../i18n"; import { defaultLang, useI18n } from "../../i18n";
import { languages } from "../../i18n"; import { languages } from "../../i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const [langCode, setLangCode] = useAtom(langCodeAtom); const { t, langCode } = useI18n();
const setLangCode = useSetAtom(appLangCodeAtom);
return ( return (
<select <select
className="dropdown-select dropdown-select__language" className="dropdown-select dropdown-select__language"
onChange={({ target }) => setLangCode(target.value)} onChange={({ target }) => setLangCode(target.value)}
value={langCode} value={langCode}
aria-label={i18n.t("buttons.selectLanguage")} aria-label={t("buttons.selectLanguage")}
style={style} style={style}
> >
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}> <option key={defaultLang.code} value={defaultLang.code}>
{i18n.defaultLang.label} {defaultLang.label}
</option> </option>
{languages.map((lang) => ( {languages.map((lang) => (
<option key={lang.code} value={lang.code}> <option key={lang.code} value={lang.code}>

View File

@ -76,13 +76,14 @@ import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData"; import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx"; import clsx from "clsx";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
import { reconcileElements } from "./collab/reconciliation"; import { reconcileElements } from "./collab/reconciliation";
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss"; import "./index.scss";
@ -227,15 +228,15 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false }; return { scene: null, isExternalScene: false };
}; };
const currentLangCode = languageDetector.detect() || defaultLang.code; const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
export const langCodeAtom = atom( Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
Array.isArray(currentLangCode) ? currentLangCode[0] : currentLangCode,
); );
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(langCodeAtom); const [langCode, setLangCode] = useAtom(appLangCodeAtom);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -688,7 +689,7 @@ const ExcalidrawWrapper = () => {
const ExcalidrawApp = () => { const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => jotaiStore}> <Provider unstable_createStore={() => appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawWrapper />
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>

View File

@ -1,6 +1,8 @@
import fallbackLangData from "./locales/en.json"; import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json"; import percentages from "./locales/percentages.json";
import { ENV } from "./constants"; import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@ -126,6 +128,8 @@ export const setLanguage = async (lang: Language) => {
currentLangData = fallbackLangData; currentLangData = fallbackLangData;
} }
} }
jotaiStore.set(editorLangCodeAtom, lang.code);
}; };
export const getLanguage = () => currentLang; export const getLanguage = () => currentLang;
@ -177,3 +181,15 @@ export const t = (
} }
return translation; return translation;
}; };
/** @private atom used solely to rerender components using `useI18n` hook */
const editorLangCodeAtom = atom(defaultLang.code);
// Should be used in components that fall under these cases:
// - component is rendered as an <Excalidraw> child
// - component is rendered internally by <Excalidraw>, but the component
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
export const useI18n = () => {
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
return { t, langCode };
};

View File

@ -1,4 +1,4 @@
import { unstable_createStore, useAtom, WritableAtom } from "jotai"; import { PrimitiveAtom, unstable_createStore, useAtom } from "jotai";
import { useLayoutEffect } from "react"; import { useLayoutEffect } from "react";
export const jotaiScope = Symbol(); export const jotaiScope = Symbol();
@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = < export const useAtomWithInitialValue = <
T extends unknown, T extends unknown,
A extends WritableAtom<T, T>, A extends PrimitiveAtom<T>,
>( >(
atom: A, atom: A,
initialValue: T | (() => T), initialValue: T | (() => T),

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.", "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟", "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟", "removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل." "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع الملف غير مدعوم.", "unsupportedFileType": "نوع الملف غير مدعوم.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Този файлов формат не се поддържа.", "unsupportedFileType": "Този файлов формат не се поддържа.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷", "invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?", "resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?", "removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।" "invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "অসমর্থিত ফাইল।", "unsupportedFileType": "অসমর্থিত ফাইল।",

View File

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Enganxa", "paste": "Enganxa",
"pasteAsPlaintext": "", "pasteAsPlaintext": "Enganxar com a text pla",
"pasteCharts": "Enganxa els diagrames", "pasteCharts": "Enganxa els diagrames",
"selectAll": "Selecciona-ho tot", "selectAll": "Selecciona-ho tot",
"multiSelect": "Afegeix un element a la selecció", "multiSelect": "Afegeix un element a la selecció",
@ -72,7 +72,7 @@
"layers": "Capes", "layers": "Capes",
"actions": "Accions", "actions": "Accions",
"language": "Llengua", "language": "Llengua",
"liveCollaboration": "", "liveCollaboration": "Col·laboració en directe...",
"duplicateSelection": "Duplica", "duplicateSelection": "Duplica",
"untitled": "Sense títol", "untitled": "Sense títol",
"name": "Nom", "name": "Nom",
@ -116,8 +116,8 @@
"label": "Enllaç" "label": "Enllaç"
}, },
"lineEditor": { "lineEditor": {
"edit": "", "edit": "Editar línia",
"exit": "" "exit": "Sortir de l'editor de línia"
}, },
"elementLock": { "elementLock": {
"lock": "Bloca", "lock": "Bloca",
@ -136,8 +136,8 @@
"buttons": { "buttons": {
"clearReset": "Neteja el llenç", "clearReset": "Neteja el llenç",
"exportJSON": "Exporta a un fitxer", "exportJSON": "Exporta a un fitxer",
"exportImage": "", "exportImage": "Exporta la imatge...",
"export": "", "export": "Guardar a...",
"exportToPng": "Exporta a PNG", "exportToPng": "Exporta a PNG",
"exportToSvg": "Exporta a SNG", "exportToSvg": "Exporta a SNG",
"copyToClipboard": "Copia al porta-retalls", "copyToClipboard": "Copia al porta-retalls",
@ -145,7 +145,7 @@
"scale": "Escala", "scale": "Escala",
"save": "Desa al fitxer actual", "save": "Desa al fitxer actual",
"saveAs": "Anomena i desa", "saveAs": "Anomena i desa",
"load": "", "load": "Obrir",
"getShareableLink": "Obté l'enllaç per a compartir", "getShareableLink": "Obté l'enllaç per a compartir",
"close": "Tanca", "close": "Tanca",
"selectLanguage": "Trieu la llengua", "selectLanguage": "Trieu la llengua",
@ -192,7 +192,8 @@
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.", "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?", "resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?", "removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada." "invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.",
"collabOfflineWarning": "Sense connexió a internet disponible.\nEls vostres canvis no seran guardats!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipus de fitxer no suportat.", "unsupportedFileType": "Tipus de fitxer no suportat.",
@ -202,8 +203,8 @@
"invalidSVGString": "SVG no vàlid.", "invalidSVGString": "SVG no vàlid.",
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.", "cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
"importLibraryError": "No s'ha pogut carregar la biblioteca", "importLibraryError": "No s'ha pogut carregar la biblioteca",
"collabSaveFailed": "", "collabSaveFailed": "No s'ha pogut desar a la base de dades de fons. Si els problemes persisteixen, hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "No s'ha pogut desar a la base de dades de fons, sembla que el llenç és massa gran. Hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball."
}, },
"toolBar": { "toolBar": {
"selection": "Selecció", "selection": "Selecció",
@ -217,10 +218,10 @@
"text": "Text", "text": "Text",
"library": "Biblioteca", "library": "Biblioteca",
"lock": "Mantenir activa l'eina seleccionada desprès de dibuixar", "lock": "Mantenir activa l'eina seleccionada desprès de dibuixar",
"penMode": "", "penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada", "link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador", "eraser": "Esborrador",
"hand": "" "hand": "Mà (eina de desplaçament)"
}, },
"headings": { "headings": {
"canvasActions": "Accions del llenç", "canvasActions": "Accions del llenç",
@ -228,7 +229,7 @@
"shapes": "Formes" "shapes": "Formes"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Per moure el llenç, manteniu premuda la roda del ratolí o la barra espaiadora mentre arrossegueu o utilitzeu l'eina manual",
"linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia", "linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia",
"freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar", "freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar",
"text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció", "text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció",
@ -239,7 +240,7 @@
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT", "resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
"resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT", "resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT",
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)", "rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
"lineEditor_info": "", "lineEditor_info": "Mantingueu premut Ctrl o Cmd i feu doble clic o premeu Ctrl o Cmd + Retorn per editar els punts",
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l", "lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
"lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts", "lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts",
"placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment", "placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment",
@ -247,7 +248,7 @@
"bindTextToElement": "Premeu enter per a afegir-hi text", "bindTextToElement": "Premeu enter per a afegir-hi text",
"deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament", "deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament",
"eraserRevert": "Mantingueu premuda Alt per a revertir els elements seleccionats per a esborrar", "eraserRevert": "Mantingueu premuda Alt per a revertir els elements seleccionats per a esborrar",
"firefox_clipboard_write": "" "firefox_clipboard_write": "És probable que aquesta funció es pugui activar posant la marca \"dom.events.asyncClipboard.clipboardItem\" a \"true\". Per canviar les marques del navegador al Firefox, visiteu la pàgina \"about:config\"."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització", "cannotShowPreview": "No es pot mostrar la previsualització",
@ -295,7 +296,7 @@
"blog": "Llegiu el nostre blog", "blog": "Llegiu el nostre blog",
"click": "clic", "click": "clic",
"deepSelect": "Selecció profunda", "deepSelect": "Selecció profunda",
"deepBoxSelect": "", "deepBoxSelect": "Seleccioneu profundament dins del quadre i eviteu arrossegar",
"curvedArrow": "Fletxa corba", "curvedArrow": "Fletxa corba",
"curvedLine": "Línia corba", "curvedLine": "Línia corba",
"documentation": "Documentació", "documentation": "Documentació",
@ -316,8 +317,8 @@
"zoomToFit": "Zoom per veure tots els elements", "zoomToFit": "Zoom per veure tots els elements",
"zoomToSelection": "Zoom per veure la selecció", "zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": "Blocar/desblocar la selecció", "toggleElementLock": "Blocar/desblocar la selecció",
"movePageUpDown": "", "movePageUpDown": "Mou la pàgina cap amunt/a baix",
"movePageLeftRight": "" "movePageLeftRight": "Mou la pàgina cap a l'esquerra/dreta"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Neteja el llenç" "title": "Neteja el llenç"
@ -399,7 +400,7 @@
"fileSavedToFilename": "S'ha desat a {filename}", "fileSavedToFilename": "S'ha desat a {filename}",
"canvas": "el llenç", "canvas": "el llenç",
"selection": "la selecció", "selection": "la selecció",
"pasteAsSingleElement": "" "pasteAsSingleElement": "Fer servir {{shortcut}} per enganxar com un sol element,\no enganxeu-lo en un editor de text existent"
}, },
"colors": { "colors": {
"ffffff": "Blanc", "ffffff": "Blanc",
@ -450,15 +451,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Totes les vostres dades es guarden localment al vostre navegador.",
"center_heading_plus": "", "center_heading_plus": "Vols anar a Excalidraw+ en comptes?",
"menuHint": "" "menuHint": "Exportar, preferències, llenguatges..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Exportar, preferències i més...",
"center_heading": "", "center_heading": "Diagrames. Fer. Simple.",
"toolbarHint": "", "toolbarHint": "Selecciona una eina i comença a dibuixar!",
"helpHint": "" "helpHint": "Dreceres i ajuda"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.", "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?", "resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?", "removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert." "invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.",
"collabOfflineWarning": "Keine Internetverbindung verfügbar.\nDeine Änderungen werden nicht gespeichert!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Nicht unterstützter Dateityp.", "unsupportedFileType": "Nicht unterstützter Dateityp.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.", "invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;", "resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;", "removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη." "invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
"collabOfflineWarning": "Δεν υπάρχει διαθέσιμη σύνδεση στο internet.\nΟι αλλαγές σας δεν θα αποθηκευτούν!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.", "unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",

View File

@ -103,7 +103,7 @@
"share": "Compartir", "share": "Compartir",
"showStroke": "Mostrar selector de color de trazo", "showStroke": "Mostrar selector de color de trazo",
"showBackground": "Mostrar el selector de color de fondo", "showBackground": "Mostrar el selector de color de fondo",
"toggleTheme": "Alternar tema", "toggleTheme": "Cambiar tema",
"personalLib": "Biblioteca personal", "personalLib": "Biblioteca personal",
"excalidrawLib": "Biblioteca Excalidraw", "excalidrawLib": "Biblioteca Excalidraw",
"decreaseFontSize": "Disminuir tamaño de letra", "decreaseFontSize": "Disminuir tamaño de letra",
@ -192,7 +192,8 @@
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.", "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?", "resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?", "removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada." "invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.",
"collabOfflineWarning": "No hay conexión a internet disponible.\n¡No se guardarán los cambios!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de archivo no admitido.", "unsupportedFileType": "Tipo de archivo no admitido.",
@ -233,7 +234,7 @@
"freeDraw": "Haz clic y arrastra, suelta al terminar", "freeDraw": "Haz clic y arrastra, suelta al terminar",
"text": "Consejo: también puedes añadir texto haciendo doble clic en cualquier lugar con la herramienta de selección", "text": "Consejo: también puedes añadir texto haciendo doble clic en cualquier lugar con la herramienta de selección",
"text_selected": "Doble clic o pulse ENTER para editar el texto", "text_selected": "Doble clic o pulse ENTER para editar el texto",
"text_editing": "Pulse Escape o CtrlOrCmd+ENTER para terminar de editar", "text_editing": "Pulse Escape o Ctrl/Cmd + ENTER para terminar de editar",
"linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar", "linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar",
"lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT", "lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT",
"resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro", "resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro",
@ -314,7 +315,7 @@
"title": "Ayuda", "title": "Ayuda",
"view": "Vista", "view": "Vista",
"zoomToFit": "Ajustar la vista para mostrar todos los elementos", "zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Zoom a la selección", "zoomToSelection": "Ampliar selección",
"toggleElementLock": "Bloquear/desbloquear selección", "toggleElementLock": "Bloquear/desbloquear selección",
"movePageUpDown": "Mover página hacia arriba/abajo", "movePageUpDown": "Mover página hacia arriba/abajo",
"movePageLeftRight": "Mover página hacia la izquierda/derecha" "movePageLeftRight": "Mover página hacia la izquierda/derecha"
@ -326,9 +327,9 @@
"title": "Publicar biblioteca", "title": "Publicar biblioteca",
"itemName": "Nombre del artículo", "itemName": "Nombre del artículo",
"authorName": "Nombre del autor", "authorName": "Nombre del autor",
"githubUsername": "Nombre de usuario de Github", "githubUsername": "Nombre de usuario de GitHub",
"twitterUsername": "Nombre de usuario de Twitter", "twitterUsername": "Nombre de usuario de Twitter",
"libraryName": "Nombre de la librería", "libraryName": "Nombre de la biblioteca",
"libraryDesc": "Descripción de la biblioteca", "libraryDesc": "Descripción de la biblioteca",
"website": "Sitio Web", "website": "Sitio Web",
"placeholder": { "placeholder": {
@ -336,7 +337,7 @@
"libraryName": "Nombre de tu biblioteca", "libraryName": "Nombre de tu biblioteca",
"libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso", "libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso",
"githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión", "githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión",
"twitterHandle": "Nombre de usuario de Twitter (opcional), así que sabemos a quién acreditar cuando se promociona en Twitter", "twitterHandle": "Nombre de usuario de Twitter (opcional), así sabemos a quién acreditar cuando se promociona en Twitter",
"website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)" "website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)"
}, },
"errors": { "errors": {
@ -458,7 +459,7 @@
"menuHint": "Exportar, preferencias y más...", "menuHint": "Exportar, preferencias y más...",
"center_heading": "Diagramas. Hecho. Simplemente.", "center_heading": "Diagramas. Hecho. Simplemente.",
"toolbarHint": "¡Elige una herramienta y empieza a dibujar!", "toolbarHint": "¡Elige una herramienta y empieza a dibujar!",
"helpHint": "Atajos & ayuda" "helpHint": "Atajos y ayuda"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.", "invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?", "resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?", "removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago." "invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.",
"collabOfflineWarning": "Ez dago Interneteko konexiorik.\nZure aldaketak ez dira gordeko!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Onartu gabeko fitxategi mota.", "unsupportedFileType": "Onartu gabeko fitxategi mota.",
@ -220,7 +221,7 @@
"penMode": "Luma modua - ukipena saihestu", "penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako", "link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma", "eraser": "Borragoma",
"hand": "" "hand": "Eskua (panoratze tresna)"
}, },
"headings": { "headings": {
"canvasActions": "Canvas ekintzak", "canvasActions": "Canvas ekintzak",
@ -228,7 +229,7 @@
"shapes": "Formak" "shapes": "Formak"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Oihala mugitzeko, eutsi saguaren gurpila edo zuriune-barra arrastatzean, edo erabili esku tresna",
"linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako", "linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako",
"freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan", "freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan",
"text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin", "text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin",
@ -247,7 +248,7 @@
"bindTextToElement": "Sakatu Sartu testua gehitzeko", "bindTextToElement": "Sakatu Sartu testua gehitzeko",
"deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko", "deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko",
"eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko", "eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Ezaugarri hau \"dom.events.asyncClipboard.clipboardItem\" marka \"true\" gisa ezarrita gaitu daiteke. Firefox-en arakatzailearen banderak aldatzeko, bisitatu \"about:config\" orrialdera."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Ezin da oihala aurreikusi", "cannotShowPreview": "Ezin da oihala aurreikusi",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.", "invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?", "resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?", "removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است." "invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "نوع فایل پشتیبانی نشده.", "unsupportedFileType": "نوع فایل پشتیبانی نشده.",

View File

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Liitä", "paste": "Liitä",
"pasteAsPlaintext": "", "pasteAsPlaintext": "Liitä pelkkänä tekstinä",
"pasteCharts": "Liitä kaaviot", "pasteCharts": "Liitä kaaviot",
"selectAll": "Valitse kaikki", "selectAll": "Valitse kaikki",
"multiSelect": "Lisää kohde valintaan", "multiSelect": "Lisää kohde valintaan",
@ -72,7 +72,7 @@
"layers": "Tasot", "layers": "Tasot",
"actions": "Toiminnot", "actions": "Toiminnot",
"language": "Kieli", "language": "Kieli",
"liveCollaboration": "", "liveCollaboration": "Live Yhteistyö...",
"duplicateSelection": "Monista", "duplicateSelection": "Monista",
"untitled": "Nimetön", "untitled": "Nimetön",
"name": "Nimi", "name": "Nimi",
@ -116,14 +116,14 @@
"label": "Linkki" "label": "Linkki"
}, },
"lineEditor": { "lineEditor": {
"edit": "", "edit": "Muokkaa riviä",
"exit": "" "exit": "Poistu rivieditorista"
}, },
"elementLock": { "elementLock": {
"lock": "", "lock": "Lukitse",
"unlock": "", "unlock": "Poista lukitus",
"lockAll": "", "lockAll": "Lukitse kaikki",
"unlockAll": "" "unlockAll": "Poista lukitus kaikista"
}, },
"statusPublished": "Julkaistu", "statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna" "sidebarLock": "Pidä sivupalkki avoinna"
@ -136,8 +136,8 @@
"buttons": { "buttons": {
"clearReset": "Tyhjennä piirtoalue", "clearReset": "Tyhjennä piirtoalue",
"exportJSON": "Vie tiedostoon", "exportJSON": "Vie tiedostoon",
"exportImage": "", "exportImage": "Vie kuva...",
"export": "", "export": "Tallenna nimellä...",
"exportToPng": "Vie PNG-tiedostona", "exportToPng": "Vie PNG-tiedostona",
"exportToSvg": "Vie SVG-tiedostona", "exportToSvg": "Vie SVG-tiedostona",
"copyToClipboard": "Kopioi leikepöydälle", "copyToClipboard": "Kopioi leikepöydälle",
@ -145,7 +145,7 @@
"scale": "Koko", "scale": "Koko",
"save": "Tallenna nykyiseen tiedostoon", "save": "Tallenna nykyiseen tiedostoon",
"saveAs": "Tallenna nimellä", "saveAs": "Tallenna nimellä",
"load": "", "load": "Avaa",
"getShareableLink": "Hae jaettava linkki", "getShareableLink": "Hae jaettava linkki",
"close": "Sulje", "close": "Sulje",
"selectLanguage": "Valitse kieli", "selectLanguage": "Valitse kieli",
@ -192,7 +192,8 @@
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.", "invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?", "resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?", "removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä." "invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.",
"collabOfflineWarning": "Internet-yhteyttä ei ole saatavilla.\nMuutoksiasi ei tallenneta!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tiedostotyyppiä ei tueta.", "unsupportedFileType": "Tiedostotyyppiä ei tueta.",
@ -201,9 +202,9 @@
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.", "svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
"invalidSVGString": "Virheellinen SVG.", "invalidSVGString": "Virheellinen SVG.",
"cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.", "cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.",
"importLibraryError": "", "importLibraryError": "Kokoelman lataaminen epäonnistui",
"collabSaveFailed": "", "collabSaveFailed": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi.",
"collabSaveFailed_sizeExceeded": "" "collabSaveFailed_sizeExceeded": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi."
}, },
"toolBar": { "toolBar": {
"selection": "Valinta", "selection": "Valinta",
@ -217,10 +218,10 @@
"text": "Teksti", "text": "Teksti",
"library": "Kirjasto", "library": "Kirjasto",
"lock": "Pidä valittu työkalu aktiivisena piirron jälkeen", "lock": "Pidä valittu työkalu aktiivisena piirron jälkeen",
"penMode": "", "penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle", "link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu", "eraser": "Poistotyökalu",
"hand": "" "hand": "Käsi (panning-työkalu)"
}, },
"headings": { "headings": {
"canvasActions": "Piirtoalueen toiminnot", "canvasActions": "Piirtoalueen toiminnot",
@ -228,7 +229,7 @@
"shapes": "Muodot" "shapes": "Muodot"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Piirtoalueen liikuttamiseksi pidä hiiren pyörää tai välilyöntiä pohjassa tai käytä käsityökalua",
"linearElement": "Klikkaa piirtääksesi useampi piste, raahaa piirtääksesi yksittäinen viiva", "linearElement": "Klikkaa piirtääksesi useampi piste, raahaa piirtääksesi yksittäinen viiva",
"freeDraw": "Paina ja raahaa, päästä irti kun olet valmis", "freeDraw": "Paina ja raahaa, päästä irti kun olet valmis",
"text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla", "text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla",
@ -239,7 +240,7 @@
"resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen", "resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen",
"resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri", "resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri",
"rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi", "rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi",
"lineEditor_info": "", "lineEditor_info": "Pidä CtrlOrCmd pohjassa ja kaksoisnapsauta tai paina CtrlOrCmd + Enter muokataksesi pisteitä",
"lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla", "lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla",
"lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä", "lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä",
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti", "placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
@ -247,7 +248,7 @@
"bindTextToElement": "Lisää tekstiä painamalla enter", "bindTextToElement": "Lisää tekstiä painamalla enter",
"deepBoxSelect": "Käytä syvävalintaa ja estä raahaus painamalla CtrlOrCmd", "deepBoxSelect": "Käytä syvävalintaa ja estä raahaus painamalla CtrlOrCmd",
"eraserRevert": "Pidä Alt alaspainettuna, kumotaksesi merkittyjen elementtien poistamisen", "eraserRevert": "Pidä Alt alaspainettuna, kumotaksesi merkittyjen elementtien poistamisen",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Tämä ominaisuus voidaan todennäköisesti ottaa käyttöön asettamalla \"dom.events.asyncClipboard.clipboardItem\" kohta \"true\":ksi. Vaihtaaksesi selaimen kohdan Firefoxissa, käy \"about:config\" sivulla."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Esikatselua ei voitu näyttää", "cannotShowPreview": "Esikatselua ei voitu näyttää",
@ -315,9 +316,9 @@
"view": "Näkymä", "view": "Näkymä",
"zoomToFit": "Näytä kaikki elementit", "zoomToFit": "Näytä kaikki elementit",
"zoomToSelection": "Näytä valinta", "zoomToSelection": "Näytä valinta",
"toggleElementLock": "", "toggleElementLock": "Lukitse / poista lukitus valinta",
"movePageUpDown": "", "movePageUpDown": "Siirrä sivua ylös/alas",
"movePageLeftRight": "" "movePageLeftRight": "Siirrä sivua vasemmalle/oikealle"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Pyyhi piirtoalue" "title": "Pyyhi piirtoalue"
@ -399,7 +400,7 @@
"fileSavedToFilename": "Tallennettiin kohteeseen {filename}", "fileSavedToFilename": "Tallennettiin kohteeseen {filename}",
"canvas": "piirtoalue", "canvas": "piirtoalue",
"selection": "valinta", "selection": "valinta",
"pasteAsSingleElement": "" "pasteAsSingleElement": "Käytä {{shortcut}} liittääksesi yhtenä elementtinä,\ntai liittääksesi olemassa olevaan tekstieditoriin"
}, },
"colors": { "colors": {
"ffffff": "Valkoinen", "ffffff": "Valkoinen",
@ -450,15 +451,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Kaikki tietosi on tallennettu paikallisesti selaimellesi.",
"center_heading_plus": "", "center_heading_plus": "Haluatko sen sijaan mennä Excalidraw+:aan?",
"menuHint": "" "menuHint": "Vie, asetukset, kielet, ..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Vie, asetukset ja lisää...",
"center_heading": "", "center_heading": "Kaaviot. Tehty. Yksinkertaiseksi.",
"toolbarHint": "", "toolbarHint": "Valitse työkalu ja aloita piirtäminen!",
"helpHint": "" "helpHint": "Pikanäppäimet & ohje"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.", "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 ?", "resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?", "removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée." "invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée.",
"collabOfflineWarning": "Aucune connexion internet disponible.\nVos modifications ne seront pas enregistrées !"
}, },
"errors": { "errors": {
"unsupportedFileType": "Type de fichier non supporté.", "unsupportedFileType": "Type de fichier non supporté.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Non se puido importar a escena dende a URL proporcionada. Ou ben está malformada ou non contén un JSON con información válida para Excalidraw.", "invalidSceneUrl": "Non se puido importar a escena dende a URL proporcionada. Ou ben está malformada ou non contén un JSON con información válida para Excalidraw.",
"resetLibrary": "Isto limpará a súa biblioteca. Está seguro?", "resetLibrary": "Isto limpará a súa biblioteca. Está seguro?",
"removeItemsFromsLibrary": "Eliminar {{count}} elemento(s) da biblioteca?", "removeItemsFromsLibrary": "Eliminar {{count}} elemento(s) da biblioteca?",
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada." "invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de ficheiro non soportado.", "unsupportedFileType": "Tipo de ficheiro non soportado.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.", "invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?", "resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?", "removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל." "invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "סוג הקובץ אינו נתמך.", "unsupportedFileType": "סוג הקובץ אינו נתמך.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।", "invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।",
"resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?", "resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?",
"removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?", "removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?",
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं" "invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं",
"collabOfflineWarning": "कोई इंटरनेट कनेक्शन उपलब्ध नहीं है।\nआपके बदलाव सहेजे नहीं जाएंगे!"
}, },
"errors": { "errors": {
"unsupportedFileType": "असमर्थित फाइल प्रकार", "unsupportedFileType": "असमर्थित फाइल प्रकार",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.", "invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.",
"resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?", "resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?", "removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?",
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva." "invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Nem támogatott fájltípus.", "unsupportedFileType": "Nem támogatott fájltípus.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.", "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?", "resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?", "removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan." "invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipe file tidak didukung.", "unsupportedFileType": "Tipe file tidak didukung.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.", "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?", "resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?", "removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?",
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata." "invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata.",
"collabOfflineWarning": "Nessuna connessione internet disponibile.\nLe tue modifiche non verranno salvate!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo di file non supportato.", "unsupportedFileType": "Tipo di file non supportato.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。", "invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
"resetLibrary": "ライブラリを消去します。本当によろしいですか?", "resetLibrary": "ライブラリを消去します。本当によろしいですか?",
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?", "removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。" "invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。",
"collabOfflineWarning": "インターネットに接続されていません。\n変更は保存されません"
}, },
"errors": { "errors": {
"unsupportedFileType": "サポートされていないファイル形式です。", "unsupportedFileType": "サポートされていないファイル形式です。",
@ -220,7 +221,7 @@
"penMode": "ペンモード - タッチ防止", "penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新", "link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム", "eraser": "消しゴム",
"hand": "" "hand": "手 (パンニングツール)"
}, },
"headings": { "headings": {
"canvasActions": "キャンバス操作", "canvasActions": "キャンバス操作",
@ -228,7 +229,7 @@
"shapes": "図形" "shapes": "図形"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "キャンバスを移動するには、マウスホイールまたはスペースバーを押しながらドラッグするか、手ツールを使用します",
"linearElement": "クリックすると複数の頂点からなる曲線を開始、ドラッグすると直線", "linearElement": "クリックすると複数の頂点からなる曲線を開始、ドラッグすると直線",
"freeDraw": "クリックしてドラッグします。離すと終了します", "freeDraw": "クリックしてドラッグします。離すと終了します",
"text": "ヒント: 選択ツールを使用して任意の場所をダブルクリックしてテキストを追加することもできます", "text": "ヒント: 選択ツールを使用して任意の場所をダブルクリックしてテキストを追加することもできます",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.", "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ḍ?", "resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
"removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?", "removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?",
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa." "invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.", "unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "제공된 URL에서 화면을 가져오는데 실패했습니다. 주소가 잘못되거나, 유효한 Excalidraw JSON 데이터를 포함하고 있지 않은 것일 수 있습니다.", "invalidSceneUrl": "제공된 URL에서 화면을 가져오는데 실패했습니다. 주소가 잘못되거나, 유효한 Excalidraw JSON 데이터를 포함하고 있지 않은 것일 수 있습니다.",
"resetLibrary": "당신의 라이브러리를 초기화 합니다. 계속하시겠습니까?", "resetLibrary": "당신의 라이브러리를 초기화 합니다. 계속하시겠습니까?",
"removeItemsFromsLibrary": "{{count}}개의 아이템을 라이브러리에서 삭제하시겠습니까?", "removeItemsFromsLibrary": "{{count}}개의 아이템을 라이브러리에서 삭제하시겠습니까?",
"invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다." "invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "지원하지 않는 파일 형식 입니다.", "unsupportedFileType": "지원하지 않는 파일 형식 입니다.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "ناتوانێت دیمەنەکە هاوردە بکات لە URL ی دابینکراو. یان نادروستە، یان داتای \"ئێکسکالیدراو\" JSON ی دروستی تێدا نییە.", "invalidSceneUrl": "ناتوانێت دیمەنەکە هاوردە بکات لە URL ی دابینکراو. یان نادروستە، یان داتای \"ئێکسکالیدراو\" JSON ی دروستی تێدا نییە.",
"resetLibrary": "ئەمە کتێبخانەکەت خاوێن دەکاتەوە. ئایا دڵنیایت?", "resetLibrary": "ئەمە کتێبخانەکەت خاوێن دەکاتەوە. ئایا دڵنیایت?",
"removeItemsFromsLibrary": "سڕینەوەی {{count}} ئایتم(ەکان) لە کتێبخانە؟", "removeItemsFromsLibrary": "سڕینەوەی {{count}} ئایتم(ەکان) لە کتێبخانە؟",
"invalidEncryptionKey": "کلیلی رەمزاندن دەبێت لە 22 پیت بێت. هاوکاری ڕاستەوخۆ لە کارخراوە." "invalidEncryptionKey": "کلیلی رەمزاندن دەبێت لە 22 پیت بێت. هاوکاری ڕاستەوخۆ لە کارخراوە.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "جۆری فایلی پشتگیری نەکراو.", "unsupportedFileType": "جۆری فایلی پشتگیری نەکراو.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Nepavyko suimportuoti scenos iš pateiktos nuorodos (URL). Ji arba blogai suformatuota, arba savyje neturi teisingų Excalidraw JSON duomenų.", "invalidSceneUrl": "Nepavyko suimportuoti scenos iš pateiktos nuorodos (URL). Ji arba blogai suformatuota, arba savyje neturi teisingų Excalidraw JSON duomenų.",
"resetLibrary": "Tai išvalys tavo biblioteką. Ar tikrai to nori?", "resetLibrary": "Tai išvalys tavo biblioteką. Ar tikrai to nori?",
"removeItemsFromsLibrary": "Ištrinti {{count}} elementą/-us iš bibliotekos?", "removeItemsFromsLibrary": "Ištrinti {{count}} elementą/-us iš bibliotekos?",
"invalidEncryptionKey": "Šifravimo raktas turi būti iš 22 simbolių. Redagavimas gyvai yra išjungtas." "invalidEncryptionKey": "Šifravimo raktas turi būti iš 22 simbolių. Redagavimas gyvai yra išjungtas.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Nepalaikomas failo tipas.", "unsupportedFileType": "Nepalaikomas failo tipas.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Nevarēja importēt ainu no norādītā URL. Vai nu tas ir nederīgs, vai nesatur derīgus Excalidraw JSON datus.", "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?", "resetLibrary": "Šī funkcija iztukšos bibliotēku. Vai turpināt?",
"removeItemsFromsLibrary": "Vai izņemt {{count}} vienumu(s) no bibliotēkas?", "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." "invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Neatbalstīts datnes veids.", "unsupportedFileType": "Neatbalstīts datnes veids.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "दिलेल्या यू-आर-एल पासून दृश्य आणू शकलो नाही. तो एकतर बरोबार नाही आहे किंवा त्यात वैध एक्सकेलीड्रॉ जेसन डेटा नाही.", "invalidSceneUrl": "दिलेल्या यू-आर-एल पासून दृश्य आणू शकलो नाही. तो एकतर बरोबार नाही आहे किंवा त्यात वैध एक्सकेलीड्रॉ जेसन डेटा नाही.",
"resetLibrary": "पटल स्वच्छ होणार, तुम्हाला खात्री आहे का?", "resetLibrary": "पटल स्वच्छ होणार, तुम्हाला खात्री आहे का?",
"removeItemsFromsLibrary": "संग्रहातून {{count}} तत्व (एक किव्हा अनेक) काढू?", "removeItemsFromsLibrary": "संग्रहातून {{count}} तत्व (एक किव्हा अनेक) काढू?",
"invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे." "invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे.",
"collabOfflineWarning": "इंटरनेट कनेक्शन उपलब्ध नाही.\nतुमचे बदल जतन केले जाणार नाहीत!"
}, },
"errors": { "errors": {
"unsupportedFileType": "असमर्थित फाइल प्रकार.", "unsupportedFileType": "असमर्थित फाइल प्रकार.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Kunne ikke importere scene fra den oppgitte URL-en. Den er enten ødelagt, eller inneholder ikke gyldig Excalidraw JSON-data.", "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?", "resetLibrary": "Dette vil tømme biblioteket ditt. Er du sikker?",
"removeItemsFromsLibrary": "Slett {{count}} element(er) fra biblioteket?", "removeItemsFromsLibrary": "Slett {{count}} element(er) fra biblioteket?",
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert." "invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert.",
"collabOfflineWarning": "Ingen Internett-tilkobling tilgjengelig.\nEndringer dine vil ikke bli lagret!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Filtypen støttes ikke.", "unsupportedFileType": "Filtypen støttes ikke.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Kan scène niet importeren vanuit de opgegeven URL. Het is onjuist of bevat geen geldige Excalidraw JSON-gegevens.", "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?", "resetLibrary": "Dit zal je bibliotheek wissen. Weet je het zeker?",
"removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?", "removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?",
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld." "invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Niet-ondersteund bestandstype.", "unsupportedFileType": "Niet-ondersteund bestandstype.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Kunne ikkje hente noko scene frå den URL-en. Ho er anten øydelagd eller inneheld ikkje gyldig Excalidraw JSON-data.", "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?", "resetLibrary": "Dette vil fjerne alt innhald frå biblioteket. Er du sikker?",
"removeItemsFromsLibrary": "Slette {{count}} element frå biblioteket?", "removeItemsFromsLibrary": "Slette {{count}} element frå biblioteket?",
"invalidEncryptionKey": "Krypteringsnøkkelen må ha 22 teikn. Sanntidssamarbeid er deaktivert." "invalidEncryptionKey": "Krypteringsnøkkelen må ha 22 teikn. Sanntidssamarbeid er deaktivert.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Filtypen er ikkje støtta.", "unsupportedFileType": "Filtypen er ikkje støtta.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Importacion impossibla de la scèna a partir de lURL provesida. Es siá mal formatada o siá conten pas cap de donada JSON Excalidraw valida.", "invalidSceneUrl": "Importacion impossibla de la scèna a partir de lURL 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?", "resetLibrary": "Aquò suprimirà vòstra bibliotèca. O volètz vertadièrament?",
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la bibliotèca?", "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." "invalidEncryptionKey": "La clau de chiframent deu conténer 22 caractèrs. La collaboracion en dirèct es desactivada.",
"collabOfflineWarning": "Cap de connexion pas disponibla.\nVòstras modificacions seràn pas salvadas !"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipe de fichièr pas pres en carga.", "unsupportedFileType": "Tipe de fichièr pas pres en carga.",
@ -220,7 +221,7 @@
"penMode": "Mòde estilo - empachar lo contact", "penMode": "Mòde estilo - empachar lo contact",
"link": "Apondre/Actualizar lo ligam per una fòrma seleccionada", "link": "Apondre/Actualizar lo ligam per una fòrma seleccionada",
"eraser": "Goma", "eraser": "Goma",
"hand": "" "hand": "Man (aisina de desplaçament de la vista)"
}, },
"headings": { "headings": {
"canvasActions": "Accions del canabàs", "canvasActions": "Accions del canabàs",
@ -239,7 +240,7 @@
"resize": "Podètz servar las proporcions en mantenent la tòca MAJ pendent lo redimensionament,\nmantenètz la tòca ALT per redimensionar a partir del centre", "resize": "Podètz servar las proporcions en mantenent la tòca MAJ pendent lo redimensionament,\nmantenètz la tòca ALT per redimensionar a partir del centre",
"resizeImage": "Podètz retalhar liurament en quichant CTRL,\nquichatz ALT per retalhar a partir del centre", "resizeImage": "Podètz retalhar liurament en quichant CTRL,\nquichatz ALT per retalhar a partir del centre",
"rotate": "Podètz restrénger los angles en mantenent MAJ pendent la rotacion", "rotate": "Podètz restrénger los angles en mantenent MAJ pendent la rotacion",
"lineEditor_info": "", "lineEditor_info": "Tenètz quichat Ctrl o Cmd e doble clic o quichatz Ctrl o Cmd + Entrada per modificar los ponches",
"lineEditor_pointSelected": "Quichar Suprimir per tirar lo(s) punt(s),\nCtrlOCmd+D per duplicar, o lisatz per desplaçar", "lineEditor_pointSelected": "Quichar Suprimir per tirar lo(s) punt(s),\nCtrlOCmd+D per duplicar, o lisatz per desplaçar",
"lineEditor_nothingSelected": "Seleccionar un punt deditar (manténer Maj. per ne seleccionar mantun),\no manténer Alt e clicar per napondre de novèls", "lineEditor_nothingSelected": "Seleccionar un punt deditar (manténer Maj. per ne seleccionar mantun),\no manténer Alt e clicar per napondre de novèls",
"placeImage": "Clicatz per plaçar limatge, o clicatz e lisatz per definir sa talha manualament", "placeImage": "Clicatz per plaçar limatge, o clicatz e lisatz per definir sa talha manualament",
@ -316,8 +317,8 @@
"zoomToFit": "Zoomar per veire totes los elements", "zoomToFit": "Zoomar per veire totes los elements",
"zoomToSelection": "Zoomar la seleccion", "zoomToSelection": "Zoomar la seleccion",
"toggleElementLock": "Verrolhar/Desverrolhar la seleccion", "toggleElementLock": "Verrolhar/Desverrolhar la seleccion",
"movePageUpDown": "", "movePageUpDown": "Desplaçar la pagina ennaut/enbàs",
"movePageLeftRight": "" "movePageLeftRight": "Desplaçar la pagina a esquèrra/drecha"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Escafar canabàs" "title": "Escafar canabàs"

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "ਦਿੱਤੀ ਗਈ URL 'ਚੋਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਨਹੀਂ ਕਰ ਸਕੇ। ਇਹ ਜਾਂ ਤਾਂ ਖਰਾਬ ਹੈ, ਜਾਂ ਇਸ ਵਿੱਚ ਜਾਇਜ਼ Excalidraw JSON ਡਾਟਾ ਸ਼ਾਮਲ ਨਹੀਂ ਹੈ।", "invalidSceneUrl": "ਦਿੱਤੀ ਗਈ URL 'ਚੋਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਨਹੀਂ ਕਰ ਸਕੇ। ਇਹ ਜਾਂ ਤਾਂ ਖਰਾਬ ਹੈ, ਜਾਂ ਇਸ ਵਿੱਚ ਜਾਇਜ਼ Excalidraw JSON ਡਾਟਾ ਸ਼ਾਮਲ ਨਹੀਂ ਹੈ।",
"resetLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਾਫ ਕਰ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?", "resetLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਾਫ ਕਰ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"removeItemsFromsLibrary": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ {{count}} ਚੀਜ਼(-ਜ਼ਾਂ) ਮਿਟਾਉਣੀਆਂ ਹਨ?", "removeItemsFromsLibrary": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ {{count}} ਚੀਜ਼(-ਜ਼ਾਂ) ਮਿਟਾਉਣੀਆਂ ਹਨ?",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -1,26 +1,26 @@
{ {
"ar-SA": 92, "ar-SA": 92,
"bg-BG": 54, "bg-BG": 54,
"bn-BD": 60, "bn-BD": 59,
"ca-ES": 93, "ca-ES": 100,
"cs-CZ": 75, "cs-CZ": 74,
"da-DK": 33, "da-DK": 32,
"de-DE": 100, "de-DE": 100,
"el-GR": 99, "el-GR": 99,
"en": 100, "en": 100,
"es-ES": 100, "es-ES": 100,
"eu-ES": 99, "eu-ES": 100,
"fa-IR": 95, "fa-IR": 95,
"fi-FI": 92, "fi-FI": 100,
"fr-FR": 100, "fr-FR": 100,
"gl-ES": 100, "gl-ES": 99,
"he-IL": 89, "he-IL": 89,
"hi-IN": 71, "hi-IN": 71,
"hu-HU": 89, "hu-HU": 88,
"id-ID": 99, "id-ID": 99,
"it-IT": 100, "it-IT": 100,
"ja-JP": 99, "ja-JP": 100,
"kab-KAB": 94, "kab-KAB": 93,
"kk-KZ": 20, "kk-KZ": 20,
"ko-KR": 98, "ko-KR": 98,
"ku-TR": 95, "ku-TR": 95,
@ -31,22 +31,22 @@
"nb-NO": 100, "nb-NO": 100,
"nl-NL": 90, "nl-NL": 90,
"nn-NO": 89, "nn-NO": 89,
"oc-FR": 97, "oc-FR": 98,
"pa-IN": 83, "pa-IN": 82,
"pl-PL": 84, "pl-PL": 84,
"pt-BR": 97, "pt-BR": 100,
"pt-PT": 99, "pt-PT": 100,
"ro-RO": 99, "ro-RO": 100,
"ru-RU": 100, "ru-RU": 100,
"si-LK": 8, "si-LK": 8,
"sk-SK": 100, "sk-SK": 100,
"sl-SI": 100, "sl-SI": 100,
"sv-SE": 100, "sv-SE": 100,
"ta-IN": 92, "ta-IN": 94,
"tr-TR": 97, "tr-TR": 97,
"uk-UA": 96, "uk-UA": 96,
"vi-VN": 20, "vi-VN": 20,
"zh-CN": 100, "zh-CN": 100,
"zh-HK": 26, "zh-HK": 25,
"zh-TW": 100 "zh-TW": 100
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Nie udało się zaimportować sceny z podanego adresu URL. Jest ona wadliwa lub nie zawiera poprawnych danych Excalidraw w formacie JSON.", "invalidSceneUrl": "Nie udało się zaimportować sceny z podanego adresu URL. Jest ona wadliwa lub nie zawiera poprawnych danych Excalidraw w formacie JSON.",
"resetLibrary": "To wyczyści twoją bibliotekę. Jesteś pewien?", "resetLibrary": "To wyczyści twoją bibliotekę. Jesteś pewien?",
"removeItemsFromsLibrary": "Usunąć {{count}} element(ów) z biblioteki?", "removeItemsFromsLibrary": "Usunąć {{count}} element(ów) z biblioteki?",
"invalidEncryptionKey": "Klucz szyfrowania musi składać się z 22 znaków. Współpraca na żywo jest wyłączona." "invalidEncryptionKey": "Klucz szyfrowania musi składać się z 22 znaków. Współpraca na żywo jest wyłączona.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Nieobsługiwany typ pliku.", "unsupportedFileType": "Nieobsługiwany typ pliku.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Não foi possível importar a cena da URL fornecida. Ela está incompleta ou não contém dados JSON válidos do Excalidraw.", "invalidSceneUrl": "Não foi possível importar a cena da URL fornecida. Ela está incompleta ou não contém dados JSON válidos do Excalidraw.",
"resetLibrary": "Isto limpará a sua biblioteca. Você tem certeza?", "resetLibrary": "Isto limpará a sua biblioteca. Você tem certeza?",
"removeItemsFromsLibrary": "Excluir {{count}} item(ns) da biblioteca?", "removeItemsFromsLibrary": "Excluir {{count}} item(ns) da biblioteca?",
"invalidEncryptionKey": "A chave de encriptação deve ter 22 caracteres. A colaboração ao vivo está desabilitada." "invalidEncryptionKey": "A chave de encriptação deve ter 22 caracteres. A colaboração ao vivo está desabilitada.",
"collabOfflineWarning": "Sem conexão com a internet disponível.\nSuas alterações não serão salvas!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de arquivo não suportado.", "unsupportedFileType": "Tipo de arquivo não suportado.",
@ -220,7 +221,7 @@
"penMode": "Modo caneta — impede o toque", "penMode": "Modo caneta — impede o toque",
"link": "Adicionar/Atualizar link para uma forma selecionada", "link": "Adicionar/Atualizar link para uma forma selecionada",
"eraser": "Borracha", "eraser": "Borracha",
"hand": "" "hand": "Mão (ferramenta de rolagem)"
}, },
"headings": { "headings": {
"canvasActions": "Ações da tela", "canvasActions": "Ações da tela",
@ -228,7 +229,7 @@
"shapes": "Formas" "shapes": "Formas"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Para mover a tela, segure a roda do mouse ou a barra de espaço enquanto arrasta ou use a ferramenta de mão",
"linearElement": "Clique para iniciar vários pontos, arraste para uma única linha", "linearElement": "Clique para iniciar vários pontos, arraste para uma única linha",
"freeDraw": "Toque e arraste, solte quando terminar", "freeDraw": "Toque e arraste, solte quando terminar",
"text": "Dica: você também pode adicionar texto clicando duas vezes em qualquer lugar com a ferramenta de seleção", "text": "Dica: você também pode adicionar texto clicando duas vezes em qualquer lugar com a ferramenta de seleção",
@ -247,7 +248,7 @@
"bindTextToElement": "Pressione Enter para adicionar o texto", "bindTextToElement": "Pressione Enter para adicionar o texto",
"deepBoxSelect": "Segure Ctrl/Cmd para seleção profunda e para evitar arrastar", "deepBoxSelect": "Segure Ctrl/Cmd para seleção profunda e para evitar arrastar",
"eraserRevert": "Segure a tecla Alt para inverter os elementos marcados para exclusão", "eraserRevert": "Segure a tecla Alt para inverter os elementos marcados para exclusão",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Esse recurso pode ser ativado configurando a opção \"dom.events.asyncClipboard.clipboardItem\" como \"true\". Para alterar os sinalizadores do navegador no Firefox, visite a página \"about:config\"."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Não é possível mostrar pré-visualização", "cannotShowPreview": "Não é possível mostrar pré-visualização",
@ -450,15 +451,15 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Todos os dados são salvos localmente no seu navegador.",
"center_heading_plus": "", "center_heading_plus": "Você queria ir para o Excalidraw+ em vez disso?",
"menuHint": "" "menuHint": "Exportar, preferências, idiomas..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Exportar, preferências e mais...",
"center_heading": "", "center_heading": "Diagramas, Feito. Simples.",
"toolbarHint": "", "toolbarHint": "Escolha uma ferramenta e comece a desenhar!",
"helpHint": "" "helpHint": "Atalhos e ajuda"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Não foi possível importar a cena a partir do URL fornecido. Ou está mal formado ou não contém dados JSON do Excalidraw válidos.", "invalidSceneUrl": "Não foi possível importar a cena a partir do URL fornecido. Ou está mal formado ou não contém dados JSON do Excalidraw válidos.",
"resetLibrary": "Isto irá limpar a sua biblioteca. Tem a certeza?", "resetLibrary": "Isto irá limpar a sua biblioteca. Tem a certeza?",
"removeItemsFromsLibrary": "Apagar {{count}} item(ns) da biblioteca?", "removeItemsFromsLibrary": "Apagar {{count}} item(ns) da biblioteca?",
"invalidEncryptionKey": "Chave de encriptação deve ter 22 caracteres. A colaboração ao vivo está desativada." "invalidEncryptionKey": "Chave de encriptação deve ter 22 caracteres. A colaboração ao vivo está desativada.",
"collabOfflineWarning": "Sem ligação à internet disponível.\nAs suas alterações não serão salvas!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tipo de ficheiro não suportado.", "unsupportedFileType": "Tipo de ficheiro não suportado.",
@ -220,7 +221,7 @@
"penMode": "Modo caneta - impedir toque", "penMode": "Modo caneta - impedir toque",
"link": "Acrescentar/ Adicionar ligação para uma forma seleccionada", "link": "Acrescentar/ Adicionar ligação para uma forma seleccionada",
"eraser": "Borracha", "eraser": "Borracha",
"hand": "" "hand": "Mão (ferramenta de movimento da tela)"
}, },
"headings": { "headings": {
"canvasActions": "Ações da área de desenho", "canvasActions": "Ações da área de desenho",
@ -228,7 +229,7 @@
"shapes": "Formas" "shapes": "Formas"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Para mover a tela, carregue na roda do rato ou na barra de espaço enquanto arrasta, ou use a ferramenta da mão",
"linearElement": "Clique para iniciar vários pontos, arraste para uma única linha", "linearElement": "Clique para iniciar vários pontos, arraste para uma única linha",
"freeDraw": "Clique e arraste, large quando terminar", "freeDraw": "Clique e arraste, large quando terminar",
"text": "Dica: também pode adicionar texto clicando duas vezes em qualquer lugar com a ferramenta de seleção", "text": "Dica: também pode adicionar texto clicando duas vezes em qualquer lugar com a ferramenta de seleção",
@ -247,7 +248,7 @@
"bindTextToElement": "Carregue Enter para acrescentar texto", "bindTextToElement": "Carregue Enter para acrescentar texto",
"deepBoxSelect": "Mantenha a tecla CtrlOrCmd carregada para selecção profunda, impedindo o arrastamento", "deepBoxSelect": "Mantenha a tecla CtrlOrCmd carregada para selecção profunda, impedindo o arrastamento",
"eraserRevert": "Carregue também em Alt para reverter os elementos marcados para serem apagados", "eraserRevert": "Carregue também em Alt para reverter os elementos marcados para serem apagados",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Esta função pode provavelmente ser ativada definindo a opção \"dom.events.asyncClipboard.clipboardItem\" como \"true\". Para alterar os sinalizadores do navegador no Firefox, visite a página \"about:config\"."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Não é possível mostrar uma pré-visualização", "cannotShowPreview": "Não é possível mostrar uma pré-visualização",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Scena nu a putut fi importată din URL-ul furnizat. Este fie incorect formată, fie nu conține date JSON Excalidraw valide.", "invalidSceneUrl": "Scena nu a putut fi importată din URL-ul furnizat. Este fie incorect formată, fie nu conține date JSON Excalidraw valide.",
"resetLibrary": "Această opțiune va elimina conținutul din bibliotecă. Confirmi?", "resetLibrary": "Această opțiune va elimina conținutul din bibliotecă. Confirmi?",
"removeItemsFromsLibrary": "Ștergi {{count}} element(e) din bibliotecă?", "removeItemsFromsLibrary": "Ștergi {{count}} element(e) din bibliotecă?",
"invalidEncryptionKey": "Cheia de criptare trebuie să aibă 22 de caractere. Colaborarea în direct este dezactivată." "invalidEncryptionKey": "Cheia de criptare trebuie să aibă 22 de caractere. Colaborarea în direct este dezactivată.",
"collabOfflineWarning": "Nu este disponibilă nicio conexiune la internet.\nModificările nu vor fi salvate!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Tip de fișier neacceptat.", "unsupportedFileType": "Tip de fișier neacceptat.",
@ -220,7 +221,7 @@
"penMode": "Mod stilou împiedică atingerea", "penMode": "Mod stilou împiedică atingerea",
"link": "Adăugare/actualizare URL pentru forma selectată", "link": "Adăugare/actualizare URL pentru forma selectată",
"eraser": "Radieră", "eraser": "Radieră",
"hand": "" "hand": "Mână (instrument de panoramare)"
}, },
"headings": { "headings": {
"canvasActions": "Acțiuni pentru pânză", "canvasActions": "Acțiuni pentru pânză",
@ -228,7 +229,7 @@
"shapes": "Forme" "shapes": "Forme"
}, },
"hints": { "hints": {
"canvasPanning": "", "canvasPanning": "Pentru a muta pânză, ține apăsată rotița mausului sau bara de spațiu sau folosește instrumentul în formă de mână",
"linearElement": "Dă clic pentru a crea mai multe puncte, glisează pentru a forma o singură linie", "linearElement": "Dă clic pentru a crea mai multe puncte, glisează pentru a forma o singură linie",
"freeDraw": "Dă clic pe pânză și glisează cursorul, apoi eliberează-l când ai terminat", "freeDraw": "Dă clic pe pânză și glisează cursorul, apoi eliberează-l când ai terminat",
"text": "Sfat: poți adăuga text și dând dublu clic oriunde cu instrumentul de selecție", "text": "Sfat: poți adăuga text și dând dublu clic oriunde cu instrumentul de selecție",
@ -247,7 +248,7 @@
"bindTextToElement": "Apasă tasta Enter pentru a adăuga text", "bindTextToElement": "Apasă tasta Enter pentru a adăuga text",
"deepBoxSelect": "Ține apăsată tasta Ctrl sau Cmd pentru a efectua selectarea de adâncime și pentru a preveni glisarea", "deepBoxSelect": "Ține apăsată tasta Ctrl sau Cmd pentru a efectua selectarea de adâncime și pentru a preveni glisarea",
"eraserRevert": "Ține apăsată tasta Alt pentru a anula elementele marcate pentru ștergere", "eraserRevert": "Ține apăsată tasta Alt pentru a anula elementele marcate pentru ștergere",
"firefox_clipboard_write": "" "firefox_clipboard_write": "Această caracteristică poate fi probabil activată prin setarea preferinței „dom.events.asyncClipboard.clipboardItem” ca „true”. Pentru a schimba preferințele navigatorului în Firefox, accesează pagina „about:config”."
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Nu se poate afișa previzualizarea", "cannotShowPreview": "Nu se poate afișa previzualizarea",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Невозможно импортировать сцену с предоставленного URL. Неверный формат, или не содержит верных Excalidraw JSON данных.", "invalidSceneUrl": "Невозможно импортировать сцену с предоставленного URL. Неверный формат, или не содержит верных Excalidraw JSON данных.",
"resetLibrary": "Это очистит вашу библиотеку. Вы уверены?", "resetLibrary": "Это очистит вашу библиотеку. Вы уверены?",
"removeItemsFromsLibrary": "Удалить {{count}} объект(ов) из библиотеки?", "removeItemsFromsLibrary": "Удалить {{count}} объект(ов) из библиотеки?",
"invalidEncryptionKey": "Ключ шифрования должен состоять из 22 символов. Одновременное редактирование отключено." "invalidEncryptionKey": "Ключ шифрования должен состоять из 22 символов. Одновременное редактирование отключено.",
"collabOfflineWarning": "Отсутствует интернет-соединение.\nВаши изменения не будут сохранены!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Неподдерживаемый тип файла.", "unsupportedFileType": "Неподдерживаемый тип файла.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Nepodarilo sa načítať scénu z poskytnutej URL. Je nevalidná alebo neobsahuje žiadne validné Excalidraw JSON dáta.", "invalidSceneUrl": "Nepodarilo sa načítať scénu z poskytnutej URL. Je nevalidná alebo neobsahuje žiadne validné Excalidraw JSON dáta.",
"resetLibrary": "Týmto vyprázdnite vašu knižnicu. Ste si istý?", "resetLibrary": "Týmto vyprázdnite vašu knižnicu. Ste si istý?",
"removeItemsFromsLibrary": "Odstrániť {{count}} položiek z knižnice?", "removeItemsFromsLibrary": "Odstrániť {{count}} položiek z knižnice?",
"invalidEncryptionKey": "Šifrovací kľúč musí mať 22 znakov. Živá spolupráca je vypnutá." "invalidEncryptionKey": "Šifrovací kľúč musí mať 22 znakov. Živá spolupráca je vypnutá.",
"collabOfflineWarning": "Internetové pripojenie nie je dostupné.\nVaše zmeny nebudú uložené!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Nepodporovaný typ súboru.", "unsupportedFileType": "Nepodporovaný typ súboru.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "S priloženega URL-ja ni bilo mogoče uvoziti scene. Je napačno oblikovana ali pa ne vsebuje veljavnih podatkov Excalidraw JSON.", "invalidSceneUrl": "S priloženega URL-ja ni bilo mogoče uvoziti scene. Je napačno oblikovana ali pa ne vsebuje veljavnih podatkov Excalidraw JSON.",
"resetLibrary": "To bo počistilo vašo knjižnico. Ali ste prepričani?", "resetLibrary": "To bo počistilo vašo knjižnico. Ali ste prepričani?",
"removeItemsFromsLibrary": "Izbriši elemente ({{count}}) iz knjižnice?", "removeItemsFromsLibrary": "Izbriši elemente ({{count}}) iz knjižnice?",
"invalidEncryptionKey": "Ključ za šifriranje mora vsebovati 22 znakov. Sodelovanje v živo je onemogočeno." "invalidEncryptionKey": "Ključ za šifriranje mora vsebovati 22 znakov. Sodelovanje v živo je onemogočeno.",
"collabOfflineWarning": "Internetna povezava ni na voljo.\nVaše spremembe ne bodo shranjene!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Nepodprt tip datoteke.", "unsupportedFileType": "Nepodprt tip datoteke.",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Det gick inte att importera skiss från den angivna webbadressen. Antingen har den fel format, eller så innehåller den ingen giltig Excalidraw JSON data.", "invalidSceneUrl": "Det gick inte att importera skiss från den angivna webbadressen. Antingen har den fel format, eller så innehåller den ingen giltig Excalidraw JSON data.",
"resetLibrary": "Detta kommer att rensa ditt bibliotek. Är du säker?", "resetLibrary": "Detta kommer att rensa ditt bibliotek. Är du säker?",
"removeItemsFromsLibrary": "Ta bort {{count}} objekt från biblioteket?", "removeItemsFromsLibrary": "Ta bort {{count}} objekt från biblioteket?",
"invalidEncryptionKey": "Krypteringsnyckeln måste vara 22 tecken. Livesamarbetet är inaktiverat." "invalidEncryptionKey": "Krypteringsnyckeln måste vara 22 tecken. Livesamarbetet är inaktiverat.",
"collabOfflineWarning": "Ingen internetanslutning tillgänglig.\nDina ändringar kommer inte att sparas!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Filtypen stöds inte.", "unsupportedFileType": "Filtypen stöds inte.",

View File

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "ஒட்டு", "paste": "ஒட்டு",
"pasteAsPlaintext": "", "pasteAsPlaintext": "அலங்காரமின்றி ஒட்டு",
"pasteCharts": "விளக்கப்படங்களை ஒட்டு", "pasteCharts": "விளக்கப்படங்களை ஒட்டு",
"selectAll": "எல்லாம் தேர்ந்தெடு", "selectAll": "எல்லாம் தேர்ந்தெடு",
"multiSelect": "உறுப்பைத் தெரிவில் சேர்", "multiSelect": "உறுப்பைத் தெரிவில் சேர்",
@ -54,7 +54,7 @@
"veryLarge": "மிகப் பெரிய", "veryLarge": "மிகப் பெரிய",
"solid": "திடமான", "solid": "திடமான",
"hachure": "மலைக்குறிக்கோடு", "hachure": "மலைக்குறிக்கோடு",
"crossHatch": "", "crossHatch": "குறுக்குகதவு",
"thin": "மெல்லிய", "thin": "மெல்லிய",
"bold": "பட்டை", "bold": "பட்டை",
"left": "இடது", "left": "இடது",
@ -72,7 +72,7 @@
"layers": "அடுக்குகள்", "layers": "அடுக்குகள்",
"actions": "செயல்கள்", "actions": "செயல்கள்",
"language": "மொழி", "language": "மொழி",
"liveCollaboration": "", "liveCollaboration": "நேரடி கூட்டுப்பணி...",
"duplicateSelection": "நகலாக்கு", "duplicateSelection": "நகலாக்கு",
"untitled": "தலைப்பற்றது", "untitled": "தலைப்பற்றது",
"name": "பெயர்", "name": "பெயர்",
@ -116,7 +116,7 @@
"label": "தொடுப்பு" "label": "தொடுப்பு"
}, },
"lineEditor": { "lineEditor": {
"edit": "", "edit": "தொடுப்பைத் திருத்து",
"exit": "" "exit": ""
}, },
"elementLock": { "elementLock": {
@ -137,7 +137,7 @@
"clearReset": "கித்தானை அகரமாக்கு", "clearReset": "கித்தானை அகரமாக்கு",
"exportJSON": "கோப்புக்கு ஏற்றுமதிசெய்", "exportJSON": "கோப்புக்கு ஏற்றுமதிசெய்",
"exportImage": "", "exportImage": "",
"export": "", "export": "இதில் சேமி...",
"exportToPng": "PNGக்கு ஏற்றுமதிசெய்", "exportToPng": "PNGக்கு ஏற்றுமதிசெய்",
"exportToSvg": "SVGக்கு ஏற்றுமதிசெய்", "exportToSvg": "SVGக்கு ஏற்றுமதிசெய்",
"copyToClipboard": "நகலகத்திற்கு நகலெடு", "copyToClipboard": "நகலகத்திற்கு நகலெடு",
@ -145,7 +145,7 @@
"scale": "அளவு", "scale": "அளவு",
"save": "தற்போதைய கோப்புக்குச் சேமி", "save": "தற்போதைய கோப்புக்குச் சேமி",
"saveAs": "இப்படி சேமி", "saveAs": "இப்படி சேமி",
"load": "", "load": "திற",
"getShareableLink": "பகிரக்கூடிய தொடுப்பைப் பெறு", "getShareableLink": "பகிரக்கூடிய தொடுப்பைப் பெறு",
"close": "மூடு", "close": "மூடு",
"selectLanguage": "மொழியைத் தேர்ந்தெடு", "selectLanguage": "மொழியைத் தேர்ந்தெடு",
@ -192,7 +192,8 @@
"invalidSceneUrl": "வழங்கப்பட்ட உரலியிலிருந்து காட்சியை இறக்கவியலா. இது தவறான வடிவத்தில் உள்ளது, அ செல்லத்தக்க எக்ஸ்கேலிட்ரா JSON தரவைக் கொண்டில்லை.", "invalidSceneUrl": "வழங்கப்பட்ட உரலியிலிருந்து காட்சியை இறக்கவியலா. இது தவறான வடிவத்தில் உள்ளது, அ செல்லத்தக்க எக்ஸ்கேலிட்ரா JSON தரவைக் கொண்டில்லை.",
"resetLibrary": "இது உங்கள் நுலகத்தைத் துடைக்கும். நீங்கள் உறுதியா?", "resetLibrary": "இது உங்கள் நுலகத்தைத் துடைக்கும். நீங்கள் உறுதியா?",
"removeItemsFromsLibrary": "{{count}} உருப்படி(கள்)-ஐ உம் நூலகத்திலிருந்து அழிக்கவா?", "removeItemsFromsLibrary": "{{count}} உருப்படி(கள்)-ஐ உம் நூலகத்திலிருந்து அழிக்கவா?",
"invalidEncryptionKey": "மறையாக்க விசை 22 வரியுருக்கள் கொண்டிருக்கவேண்டும். நேரடி கூட்டுப்பணி முடக்கப்பட்டது." "invalidEncryptionKey": "மறையாக்க விசை 22 வரியுருக்கள் கொண்டிருக்கவேண்டும். நேரடி கூட்டுப்பணி முடக்கப்பட்டது.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "ஆதரிக்கப்படா கோப்பு வகை.", "unsupportedFileType": "ஆதரிக்கப்படா கோப்பு வகை.",
@ -456,9 +457,9 @@
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "",
"center_heading": "", "center_heading": "எளிமையாக வரைபடங்கள் உருவாக்க!",
"toolbarHint": "", "toolbarHint": "கருவியைத் தேர்ந்தெடு & வரை!",
"helpHint": "" "helpHint": "குறுக்குவழிகள் & உதவி"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "Verilen bağlantıdan çalışma alanı yüklenemedi. Dosya bozuk olabilir veya geçerli bir Excalidraw JSON verisi bulundurmuyor olabilir.", "invalidSceneUrl": "Verilen bağlantıdan çalışma alanı yüklenemedi. Dosya bozuk olabilir veya geçerli bir Excalidraw JSON verisi bulundurmuyor olabilir.",
"resetLibrary": "Bu işlem kütüphanenizi sıfırlayacak. Emin misiniz?", "resetLibrary": "Bu işlem kütüphanenizi sıfırlayacak. Emin misiniz?",
"removeItemsFromsLibrary": "{{count}} öğe(ler) kitaplıktan kaldırılsın mı?", "removeItemsFromsLibrary": "{{count}} öğe(ler) kitaplıktan kaldırılsın mı?",
"invalidEncryptionKey": "Şifreleme anahtarı 22 karakter olmalı. Canlı işbirliği devre dışı bırakıldı." "invalidEncryptionKey": "Şifreleme anahtarı 22 karakter olmalı. Canlı işbirliği devre dışı bırakıldı.",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Desteklenmeyen dosya türü.", "unsupportedFileType": "Desteklenmeyen dosya türü.",

View File

@ -72,7 +72,7 @@
"layers": "Шари", "layers": "Шари",
"actions": "Дії", "actions": "Дії",
"language": "Мова", "language": "Мова",
"liveCollaboration": "", "liveCollaboration": "Спільна робота у режимі реального часу...",
"duplicateSelection": "Дублювати", "duplicateSelection": "Дублювати",
"untitled": "Без назви", "untitled": "Без назви",
"name": "Ім’я", "name": "Ім’я",
@ -192,7 +192,8 @@
"invalidSceneUrl": "Не вдалося імпортувати сцену з наданого URL. Він або недоформований, або не містить дійсних даних Excalidraw JSON.", "invalidSceneUrl": "Не вдалося імпортувати сцену з наданого URL. Він або недоформований, або не містить дійсних даних Excalidraw JSON.",
"resetLibrary": "Це призведе до очищення бібліотеки. Ви впевнені?", "resetLibrary": "Це призведе до очищення бібліотеки. Ви впевнені?",
"removeItemsFromsLibrary": "Видалити {{count}} елемент(ів) з бібліотеки?", "removeItemsFromsLibrary": "Видалити {{count}} елемент(ів) з бібліотеки?",
"invalidEncryptionKey": "Ключ шифрування повинен бути довжиною до 22 символів. Спільну роботу вимкнено." "invalidEncryptionKey": "Ключ шифрування повинен бути довжиною до 22 символів. Спільну роботу вимкнено.",
"collabOfflineWarning": "Немає підключення до Інтернету.\nВаші зміни не будуть збережені!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Непідтримуваний тип файлу.", "unsupportedFileType": "Непідтримуваний тип файлу.",
@ -457,8 +458,8 @@
"defaults": { "defaults": {
"menuHint": "", "menuHint": "",
"center_heading": "", "center_heading": "",
"toolbarHint": "", "toolbarHint": "Оберіть інструмент і почніть малювати!",
"helpHint": "" "helpHint": "Гарячі клавіші і допомога"
} }
} }
} }

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "无法从提供的 URL 导入场景。它或者格式不正确,或者不包含有效的 Excalidraw JSON 数据。", "invalidSceneUrl": "无法从提供的 URL 导入场景。它或者格式不正确,或者不包含有效的 Excalidraw JSON 数据。",
"resetLibrary": "这将会清除你的素材库。你确定要这么做吗?", "resetLibrary": "这将会清除你的素材库。你确定要这么做吗?",
"removeItemsFromsLibrary": "确定要从素材库中删除 {{count}} 个项目吗?", "removeItemsFromsLibrary": "确定要从素材库中删除 {{count}} 个项目吗?",
"invalidEncryptionKey": "密钥必须包含22个字符。实时协作已被禁用。" "invalidEncryptionKey": "密钥必须包含22个字符。实时协作已被禁用。",
"collabOfflineWarning": "无网络连接。\n您的改动将不会被保存"
}, },
"errors": { "errors": {
"unsupportedFileType": "不支持的文件格式。", "unsupportedFileType": "不支持的文件格式。",
@ -239,7 +240,7 @@
"resize": "您可以按住SHIFT来限制比例大小\n按住ALT来调整中心大小", "resize": "您可以按住SHIFT来限制比例大小\n按住ALT来调整中心大小",
"resizeImage": "按住SHIFT可以自由缩放\n按住ALT可以从中间缩放", "resizeImage": "按住SHIFT可以自由缩放\n按住ALT可以从中间缩放",
"rotate": "旋转时可以按住 Shift 来约束角度", "rotate": "旋转时可以按住 Shift 来约束角度",
"lineEditor_info": "按住 CtrlOrCmd 并双击或按 CtrlOrmd + Enter 来编辑点", "lineEditor_info": "按住 CtrlOrCmd 并双击或按 CtrlOrCmd + Enter 来编辑点",
"lineEditor_pointSelected": "按下 Delete 移除点Ctrl 或 Cmd+D 以复制,拖动以移动", "lineEditor_pointSelected": "按下 Delete 移除点Ctrl 或 Cmd+D 以复制,拖动以移动",
"lineEditor_nothingSelected": "选择要编辑的点 (按住 SHIFT 选择多个)\n或按住 Alt 并点击以添加新点", "lineEditor_nothingSelected": "选择要编辑的点 (按住 SHIFT 选择多个)\n或按住 Alt 并点击以添加新点",
"placeImage": "点击放置图像,或者点击并拖动以手动设置图像大小", "placeImage": "点击放置图像,或者点击并拖动以手动设置图像大小",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "", "invalidSceneUrl": "",
"resetLibrary": "", "resetLibrary": "",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "" "invalidEncryptionKey": "",
"collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "",

View File

@ -192,7 +192,8 @@
"invalidSceneUrl": "無法由提供的 URL 匯入場景。可能是發生異常,或未包含有效的 Excalidraw JSON 資料。", "invalidSceneUrl": "無法由提供的 URL 匯入場景。可能是發生異常,或未包含有效的 Excalidraw JSON 資料。",
"resetLibrary": "這會清除您的資料庫,是否確定?", "resetLibrary": "這會清除您的資料庫,是否確定?",
"removeItemsFromsLibrary": "從資料庫刪除 {{count}} 項?", "removeItemsFromsLibrary": "從資料庫刪除 {{count}} 項?",
"invalidEncryptionKey": "加密鍵必須為22字元。即時協作已停用。" "invalidEncryptionKey": "加密鍵必須為22字元。即時協作已停用。",
"collabOfflineWarning": "沒有可用的網路連線。\n變更無法儲存"
}, },
"errors": { "errors": {
"unsupportedFileType": "不支援的檔案類型。", "unsupportedFileType": "不支援的檔案類型。",

View File

@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes - [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
```js ```js

View File

@ -87,8 +87,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
}, []); }, []);
return ( return (
<InitializeApp langCode={langCode} theme={theme}>
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}> <Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
<InitializeApp langCode={langCode} theme={theme}>
<App <App
onChange={onChange} onChange={onChange}
initialData={initialData} initialData={initialData}
@ -118,8 +118,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
> >
{children} {children}
</App> </App>
</Provider>
</InitializeApp> </InitializeApp>
</Provider>
); );
}; };
@ -198,7 +198,7 @@ export {
isInvisiblySmallElement, isInvisiblySmallElement,
getNonDeletedElements, getNonDeletedElements,
} from "../../element"; } from "../../element";
export { defaultLang, languages } from "../../i18n"; export { defaultLang, useI18n, languages } from "../../i18n";
export { export {
restore, restore,
restoreAppState, restoreAppState,

View File

@ -6,6 +6,7 @@ import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getMaxContainerWidth,
getTextWidth, getTextWidth,
measureText, measureText,
wrapText, wrapText,
@ -46,7 +47,6 @@ import {
import { mathSubtypeIcon } from "./icon"; import { mathSubtypeIcon } from "./icon";
import { getMathSubtypeRecord } from "./types"; import { getMathSubtypeRecord } from "./types";
import { SubtypeButton } from "../../../../components/Subtypes"; import { SubtypeButton } from "../../../../components/Subtypes";
import { getMaxContainerWidth } from "../../../../element/newElement";
const mathSubtype = getMathSubtypeRecord().subtype; const mathSubtype = getMathSubtypeRecord().subtype;
const FONT_FAMILY_MATH = FONT_FAMILY.Helvetica; const FONT_FAMILY_MATH = FONT_FAMILY.Helvetica;
@ -607,7 +607,10 @@ const measureMarkup = (
const grandchild = child as Text; const grandchild = child as Text;
const text = grandchild.textContent ?? ""; const text = grandchild.textContent ?? "";
if (text !== "") { if (text !== "") {
const textMetrics = measureText(text, font, maxWidth); const constrainedText = maxWidth
? wrapText(text, font, maxWidth)
: text;
const textMetrics = measureText(constrainedText, font);
childMetrics.push({ childMetrics.push({
x: nextX, x: nextX,
y: baseline, y: baseline,
@ -834,25 +837,16 @@ const cleanMathElementUpdate = function (updates) {
return oldUpdates; return oldUpdates;
} as SubtypeMethods["clean"]; } as SubtypeMethods["clean"];
const measureMathElement = function (element, next, maxWidth) { const measureMathElement = function (element, next) {
ensureMathElement(element); ensureMathElement(element);
const isMathJaxLoaded = mathJaxLoaded; const isMathJaxLoaded = mathJaxLoaded;
const fontSize = next?.fontSize ?? element.fontSize; const fontSize = next?.fontSize ?? element.fontSize;
const text = next?.text ?? element.text; const text = next?.text ?? element.text;
const customData = next?.customData ?? element.customData; const customData = next?.customData ?? element.customData;
const mathProps = getMathProps.ensureMathProps(customData); const mathProps = getMathProps.ensureMathProps(customData);
const noMaxWidth = mathProps.mathOnly; const metrics = getImageMetrics(text, fontSize, mathProps, isMathJaxLoaded);
const cWidth = noMaxWidth ? undefined : maxWidth; const { width, height } = metrics;
const metrics = getImageMetrics( return { width, height };
text,
fontSize,
mathProps,
isMathJaxLoaded,
cWidth,
);
const { height, baseline } = metrics;
const width = noMaxWidth ? maxWidth ?? metrics.width : metrics.width;
return { width, height, baseline };
} as SubtypeMethods["measureText"]; } as SubtypeMethods["measureText"];
const renderMathElement = function (element, context, renderCb) { const renderMathElement = function (element, context, renderCb) {

View File

@ -37,13 +37,11 @@ import {
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
SVG_NS, SVG_NS,
VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand"; import { getStroke, StrokeOptions } from "perfect-freehand";
import { import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getBoundTextElementOffset,
getContainerElement, getContainerElement,
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -287,22 +285,19 @@ const drawElementOnCanvas = (
const lineHeight = element.containerId const lineHeight = element.containerId
? getApproxLineHeight(getFontString(element)) ? getApproxLineHeight(getFontString(element))
: element.height / lines.length; : element.height / lines.length;
let verticalOffset = element.height - element.baseline;
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
verticalOffset = getBoundTextElementOffset(element);
}
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2
: element.textAlign === "right" : element.textAlign === "right"
? element.width ? element.width
: 0; : 0;
context.textBaseline = "bottom";
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
context.fillText( context.fillText(
lines[index], lines[index],
horizontalOffset, horizontalOffset,
(index + 1) * lineHeight - verticalOffset, (index + 1) * lineHeight,
); );
} }
context.restore(); context.restore();
@ -1312,7 +1307,7 @@ export const renderElementToSvg = (
); );
const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.height / lines.length; const lineHeight = element.height / lines.length;
const verticalOffset = element.height - element.baseline; const verticalOffset = element.height;
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2

View File

@ -231,8 +231,7 @@ export type SubtypeMethods = {
text?: string; text?: string;
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
}, },
maxWidth?: number | null, ) => { width: number; height: number };
) => { width: number; height: number; baseline: number };
render: ( render: (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 1px; height: 0px; left: 39.5px; top: 20px; transform-origin: 0.5px 0px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -20px; font: Emoji 20px 20px; line-height: 0px; font-family: Virgil, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform-origin: 5px 12px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />

View File

@ -182,3 +182,73 @@ describe("paste text as a single element", () => {
}); });
}); });
}); });
describe("Paste bound text container", () => {
const container = {
type: "ellipse",
id: "container-id",
x: 554.984375,
y: 196.0234375,
width: 166,
height: 187.01953125,
roundness: { type: 2 },
boundElements: [{ type: "text", id: "text-id" }],
};
const textElement = {
type: "text",
id: "text-id",
x: 560.51171875,
y: 202.033203125,
width: 154,
height: 175,
fontSize: 20,
fontFamily: 1,
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
baseline: 168,
textAlign: "center",
verticalAlign: "middle",
containerId: container.id,
originalText:
"Excalidraw is a virtual opensource whiteboard for sketching hand-drawn like diagrams",
};
it("should fix ellipse bounding box", async () => {
const data = JSON.stringify({
type: "excalidraw/clipboard",
elements: [container, textElement],
});
setClipboardText(data);
pasteWithCtrlCmdShiftV();
await waitFor(async () => {
await sleep(1);
expect(h.elements.length).toEqual(2);
const container = h.elements[0];
expect(container.height).toBe(354);
expect(container.width).toBe(166);
});
});
it("should fix diamond bounding box", async () => {
const data = JSON.stringify({
type: "excalidraw/clipboard",
elements: [
{
...container,
type: "diamond",
},
textElement,
],
});
setClipboardText(data);
pasteWithCtrlCmdShiftV();
await waitFor(async () => {
await sleep(1);
expect(h.elements.length).toEqual(2);
const container = h.elements[0];
expect(container.height).toBe(740);
expect(container.width).toBe(166);
});
});
});

View File

@ -282,7 +282,6 @@ exports[`restoreElements should restore text element correctly passing value for
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [], "boundElements": Array [],
"containerId": null, "containerId": null,
"fillStyle": "hachure", "fillStyle": "hachure",
@ -312,8 +311,8 @@ Object {
"versionNonce": 0, "versionNonce": 0,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 100, "width": 100,
"x": -0.5, "x": -20,
"y": 0, "y": -8.4,
} }
`; `;
@ -321,7 +320,6 @@ exports[`restoreElements should restore text element correctly with unknown font
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [], "boundElements": Array [],
"containerId": null, "containerId": null,
"fillStyle": "hachure", "fillStyle": "hachure",

View File

@ -17,8 +17,11 @@ import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { queryByTestId, queryByText } from "@testing-library/react"; import { queryByTestId, queryByText } from "@testing-library/react";
import { resize, rotate } from "./utils"; import { resize, rotate } from "./utils";
import { getBoundTextElementPosition, wrapText } from "../element/textElement"; import {
import { getMaxContainerWidth } from "../element/newElement"; getBoundTextElementPosition,
wrapText,
getMaxContainerWidth,
} from "../element/textElement";
import * as textElementUtils from "../element/textElement"; import * as textElementUtils from "../element/textElement";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
@ -1028,7 +1031,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 10, "height": 128,
"width": 367, "width": 367,
} }
`); `);
@ -1036,8 +1039,8 @@ describe("Test Linear Elements", () => {
expect(getBoundTextElementPosition(container, textElement)) expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 386.5, "x": 272,
"y": 70, "y": 46,
} }
`); `);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
@ -1049,11 +1052,11 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Array [ Array [
20, 20,
60, 36,
391.8122896842806, 502,
70, 94,
205.9061448421403, 205.9061448421403,
65, 53,
] ]
`); `);
}); });
@ -1087,7 +1090,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 0, "height": 128,
"width": 340, "width": 340,
} }
`); `);
@ -1095,8 +1098,8 @@ describe("Test Linear Elements", () => {
expect(getBoundTextElementPosition(container, textElement)) expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 189.5, "x": 75,
"y": 20, "y": -4,
} }
`); `);
expect(textElement.text).toMatchInlineSnapshot(` expect(textElement.text).toMatchInlineSnapshot(`

View File

@ -33,7 +33,7 @@ import { actionChangeRoundness } from "../actions/actionProperties";
const MW = 200; const MW = 200;
const TWIDTH = 200; const TWIDTH = 200;
const THEIGHT = 20; const THEIGHT = 20;
const TBASELINE = 15; const TBASELINE = 0;
const FONTSIZE = 20; const FONTSIZE = 20;
const DBFONTSIZE = 40; const DBFONTSIZE = 40;
const TRFONTSIZE = 60; const TRFONTSIZE = 60;
@ -155,11 +155,7 @@ const prepareTest1Subtype = function (
return { actions, methods }; return { actions, methods };
} as SubtypePrepFn; } as SubtypePrepFn;
const measureTest2: SubtypeMethods["measureText"] = function ( const measureTest2: SubtypeMethods["measureText"] = function (element, next) {
element,
next,
maxWidth,
) {
const text = next?.text ?? element.text; const text = next?.text ?? element.text;
const customData = next?.customData ?? {}; const customData = next?.customData ?? {};
const fontSize = customData.triple const fontSize = customData.triple
@ -167,10 +163,10 @@ const measureTest2: SubtypeMethods["measureText"] = function (
: next?.fontSize ?? element.fontSize; : next?.fontSize ?? element.fontSize;
const fontFamily = element.fontFamily; const fontFamily = element.fontFamily;
const fontString = getFontString({ fontSize, fontFamily }); const fontString = getFontString({ fontSize, fontFamily });
const metrics = textElementUtils.measureText(text, fontString, maxWidth); const metrics = textElementUtils.measureText(text, fontString);
const width = Math.max(metrics.width - 10, 0); const width = Math.max(metrics.width - 10, 0);
const height = Math.max(metrics.height - 5, 0); const height = Math.max(metrics.height - 5, 0);
return { width, height, baseline: metrics.baseline + 1 }; return { width, height, baseline: 1 };
}; };
const wrapTest2: SubtypeMethods["wrapText"] = function ( const wrapTest2: SubtypeMethods["wrapText"] = function (
@ -450,12 +446,6 @@ describe("subtypes", () => {
height: THEIGHT - 5, height: THEIGHT - 5,
baseline: TBASELINE + 1, baseline: TBASELINE + 1,
}); });
const mMetrics = textElementUtils.measureTextElement(el, {}, MW);
expect(mMetrics).toStrictEqual({
width: Math.min(TWIDTH, MW) - 10,
height: THEIGHT - 5,
baseline: TBASELINE + 1,
});
const wrappedText = textElementUtils.wrapTextElement(el, MW); const wrappedText = textElementUtils.wrapTextElement(el, MW);
expect(wrappedText).toEqual( expect(wrappedText).toEqual(
`${testString.split(" ").join("\n")}\nHello world.`, `${testString.split(" ").join("\n")}\nHello world.`,
@ -482,12 +472,6 @@ describe("subtypes", () => {
height: 2 * THEIGHT - 5, height: 2 * THEIGHT - 5,
baseline: 2 * TBASELINE + 1, baseline: 2 * TBASELINE + 1,
}); });
const nextFMW = textElementUtils.measureTextElement(el, next, MW);
expect(nextFMW).toStrictEqual({
width: Math.min(2 * TWIDTH, MW) - 10,
height: 2 * THEIGHT - 5,
baseline: 2 * TBASELINE + 1,
});
const nextFWrText = textElementUtils.wrapTextElement(el, MW, next); const nextFWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextFWrText).toEqual( expect(nextFWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO World.`, `${testString.split(" ").join("\n")}\nHELLO World.`,
@ -501,12 +485,6 @@ describe("subtypes", () => {
height: 3 * THEIGHT - 5, height: 3 * THEIGHT - 5,
baseline: 3 * TBASELINE + 1, baseline: 3 * TBASELINE + 1,
}); });
const nextCDMW = textElementUtils.measureTextElement(el, next, MW);
expect(nextCDMW).toStrictEqual({
width: Math.min(3 * TWIDTH, MW) - 10,
height: 3 * THEIGHT - 5,
baseline: 3 * TBASELINE + 1,
});
const nextCDWrText = textElementUtils.wrapTextElement(el, MW, next); const nextCDWrText = textElementUtils.wrapTextElement(el, MW, next);
expect(nextCDWrText).toEqual( expect(nextCDWrText).toEqual(
`${testString.split(" ").join("\n")}\nHELLO WORLD.`, `${testString.split(" ").join("\n")}\nHELLO WORLD.`,