mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
15 Commits
zsviczian-
...
aakansha-h
Author | SHA1 | Date | |
---|---|---|---|
ef8bcbe1f8 | |||
02d5cc4174 | |||
2f4d051ea1 | |||
0b0846bd8e | |||
330b3a0530 | |||
cd195374bc | |||
8971d06655 | |||
1e4e37b87f | |||
189a557ed6 | |||
bff2f9178d | |||
9b5715623a | |||
3487f0ab26 | |||
76910828c2 | |||
fde521ef4d | |||
bcb45f7cf6 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,3 +25,4 @@ src/packages/excalidraw/types
|
||||
src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||
|
||||
|
@ -41,7 +41,7 @@
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.2.0",
|
||||
"perfect-freehand": "1.0.16",
|
||||
"pica": "7.1.1",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
@ -74,6 +74,9 @@
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
|
@ -52,25 +52,6 @@
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
|
||||
<!------------------------------------------------------------------------->
|
||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||
<script>
|
||||
try {
|
||||
//
|
||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<style>
|
||||
html.dark {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
<!------------------------------------------------------------------------->
|
||||
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@ -117,7 +98,7 @@
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD==="true" ) { %>
|
||||
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||
<script>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
@ -174,7 +155,7 @@
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap;
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,6 @@ const crowdinMap = {
|
||||
"fa-IR": "en-fa",
|
||||
"fi-FI": "en-fi",
|
||||
"fr-FR": "en-fr",
|
||||
"gl-ES": "en-gl",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
@ -24,7 +23,6 @@ const crowdinMap = {
|
||||
"ja-JP": "en-ja",
|
||||
"kab-KAB": "en-kab",
|
||||
"ko-KR": "en-ko",
|
||||
"ku-TR": "en-ku",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
@ -67,7 +65,6 @@ const flags = {
|
||||
"fa-IR": "🇮🇷",
|
||||
"fi-FI": "🇫🇮",
|
||||
"fr-FR": "🇫🇷",
|
||||
"gl-ES": "🇪🇸",
|
||||
"he-IL": "🇮🇱",
|
||||
"hi-IN": "🇮🇳",
|
||||
"hu-HU": "🇭🇺",
|
||||
@ -77,7 +74,6 @@ const flags = {
|
||||
"kab-KAB": "🏳",
|
||||
"kk-KZ": "🇰🇿",
|
||||
"ko-KR": "🇰🇷",
|
||||
"ku-TR": "🏳",
|
||||
"lt-LT": "🇱🇹",
|
||||
"lv-LV": "🇱🇻",
|
||||
"my-MM": "🇲🇲",
|
||||
|
@ -2,7 +2,7 @@ import { ColorPicker } from "../components/ColorPicker";
|
||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -206,7 +206,7 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
const clampedZoomValueToFitElements = Math.min(
|
||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
||||
1,
|
||||
);
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
|
@ -36,7 +36,7 @@ export const actionCut = register({
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
|
@ -33,6 +33,9 @@ export const actionFinalize = register({
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
const selectedLinearElement = appState.selectedLinearElement
|
||||
? new LinearElementEditor(element, scene, appState)
|
||||
: null;
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
@ -42,6 +45,7 @@ export const actionFinalize = register({
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@ -184,7 +188,7 @@ export const actionFinalize = register({
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
? new LinearElementEditor(multiPointElement, scene, appState)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
|
@ -6,14 +6,10 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getElementPointsCoords,
|
||||
} from "../element/bounds";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -122,6 +118,13 @@ const flipElement = (
|
||||
const height = element.height;
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
// Rotate back to zero, if necessary
|
||||
mutateElement(element, {
|
||||
angle: normalizeAngle(0),
|
||||
@ -129,6 +132,7 @@ const flipElement = (
|
||||
// Flip unrotated by pulling TransformHandle to opposite side
|
||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||
let usingNWHandle = true;
|
||||
let newNCoordsX = 0;
|
||||
let nHandle = transformHandles.nw;
|
||||
if (!nHandle) {
|
||||
// Use ne handle instead
|
||||
@ -142,51 +146,30 @@ const flipElement = (
|
||||
}
|
||||
}
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element) && element.points.length < 3) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
let initialPointsCoords;
|
||||
if (isLinearElement(element)) {
|
||||
initialPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
}
|
||||
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
if (isLinearElement(element) && element.points.length < 3) {
|
||||
for (let index = 1; index < element.points.length; index++) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index,
|
||||
point: [-element.points[index][0], element.points[index][1]],
|
||||
},
|
||||
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||
]);
|
||||
}
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
} else {
|
||||
const elWidth = initialPointsCoords
|
||||
? initialPointsCoords[2] - initialPointsCoords[0]
|
||||
: initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
|
||||
|
||||
const startPoint = initialPointsCoords
|
||||
? [initialPointsCoords[0], initialPointsCoords[1]]
|
||||
: [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
|
||||
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
new Map().set(element.id, element),
|
||||
false,
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
true,
|
||||
usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
|
||||
startPoint[1],
|
||||
false,
|
||||
newNCoordsX,
|
||||
nHandle[1],
|
||||
);
|
||||
// fix the size to account for handle sizes
|
||||
mutateElement(element, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by (360 degrees - original angle)
|
||||
@ -203,34 +186,9 @@ const flipElement = (
|
||||
mutateElement(element, {
|
||||
x: originalX + finalOffsetX,
|
||||
y: originalY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
updateBoundElements(element);
|
||||
|
||||
if (initialPointsCoords && isLinearElement(element)) {
|
||||
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
|
||||
// There's still room for improvement since when the line roughness is > 1
|
||||
// we still have a small offset of the origin when fliipping the element.
|
||||
const finalPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
|
||||
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
|
||||
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
|
||||
|
||||
const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
|
||||
|
||||
mutateElement(element, {
|
||||
x: element.x + coordsDiff * 0.5,
|
||||
y: element.y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
@ -132,7 +132,7 @@ export const actionGroup = register({
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
@ -189,9 +189,7 @@ export const actionUngroup = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
@ -1,49 +0,0 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, app.scene);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
});
|
@ -4,7 +4,7 @@ import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
@ -67,7 +67,7 @@ export const actionFullScreen = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
@ -35,7 +35,7 @@ export const actionSelectAll = register({
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
? new LinearElementEditor(elements[0], app.scene, appState)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
|
@ -85,4 +85,3 @@ export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
@ -137,6 +137,7 @@ export class ActionManager {
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
this.actions[name] &&
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
|
@ -111,8 +111,7 @@ export type ActionName =
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor";
|
||||
| "toggleLock";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
@ -57,7 +57,8 @@ export const getDefaultAppState = (): Omit<
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isSidebarDocked: false,
|
||||
isLibraryOpen: false,
|
||||
isLibraryMenuDocked: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@ -66,7 +67,6 @@ export const getDefaultAppState = (): Omit<
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@ -148,7 +148,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isSidebarDocked: { browser: true, export: false, server: false },
|
||||
isLibraryOpen: { browser: true, export: false, server: false },
|
||||
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
@ -159,7 +160,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
openSidebar: { browser: true, export: false, server: false },
|
||||
pasteDialog: { browser: false, export: false, server: false },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
|
@ -34,7 +34,6 @@ import {
|
||||
actionUngroup,
|
||||
actionLink,
|
||||
actionToggleLock,
|
||||
actionToggleLinearEditor,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
@ -76,7 +75,6 @@ import {
|
||||
THEME,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
VERTICAL_ALIGN,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
@ -169,7 +167,6 @@ import {
|
||||
isArrowKey,
|
||||
KEYS,
|
||||
isAndroid,
|
||||
isDarwin,
|
||||
} from "../keys";
|
||||
import { distance2d, getGridPoint, isPathALoop } from "../math";
|
||||
import { renderScene } from "../renderer/renderScene";
|
||||
@ -255,7 +252,6 @@ import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerDims,
|
||||
} from "../element/textElement";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
@ -295,17 +291,10 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
});
|
||||
|
||||
const ExcalidrawSetAppStateContent = React.createContext<
|
||||
React.Component<any, AppState>["setState"]
|
||||
>(() => {});
|
||||
|
||||
export const useExcalidrawElements = () =>
|
||||
useContext(ExcalidrawElementsContext);
|
||||
export const useExcalidrawAppState = () =>
|
||||
useContext(ExcalidrawAppStateContext);
|
||||
export const useExcalidrawSetAppState = () =>
|
||||
useContext(ExcalidrawSetAppStateContent);
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
@ -389,7 +378,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
showHyperlinkPopup: false,
|
||||
isSidebarDocked: false,
|
||||
isLibraryMenuDocked: false,
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
@ -421,7 +410,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
toggleMenu: this.toggleMenu,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
@ -534,68 +522,69 @@ class App extends React.Component<AppProps, AppState> {
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<DeviceContext.Provider value={this.device}>
|
||||
<ExcalidrawSetAppStateContent.Provider value={this.setAppState}>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomFooter={renderFooter}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderCustomSidebar={this.props.renderSidebar}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
/>
|
||||
)}
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomFooter={renderFooter}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
showThemeBtn={
|
||||
typeof this.props?.theme === "undefined" &&
|
||||
this.props.UIOptions.canvasActions.theme
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</ExcalidrawElementsContext.Provider>{" "}
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContent.Provider>
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</ExcalidrawElementsContext.Provider>{" "}
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</DeviceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</div>
|
||||
@ -656,8 +645,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||
let gridSize = actionResult?.appState?.gridSize || null;
|
||||
const theme =
|
||||
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
|
||||
let theme = actionResult?.appState?.theme || THEME.LIGHT;
|
||||
let name = actionResult?.appState?.name ?? this.state.name;
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
@ -671,6 +659,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
||||
}
|
||||
|
||||
if (typeof this.props.theme !== "undefined") {
|
||||
theme = this.props.theme;
|
||||
}
|
||||
|
||||
if (typeof this.props.name !== "undefined") {
|
||||
name = this.props.name;
|
||||
}
|
||||
@ -763,9 +755,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.theme) {
|
||||
this.setState({ theme: this.props.theme });
|
||||
}
|
||||
if (!this.state.isLoading) {
|
||||
this.setState({ isLoading: true });
|
||||
}
|
||||
@ -795,12 +784,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const scene = restore(initialData, null, null);
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
theme: this.props.theme || scene.appState.theme,
|
||||
// we're falling back to current (pre-init) state when deciding
|
||||
// whether to open the library, to handle a case where we
|
||||
// update the state outside of initialData (e.g. when loading the app
|
||||
// with a library install link, which should auto-open the library)
|
||||
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
||||
isLibraryOpen:
|
||||
initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen,
|
||||
activeTool:
|
||||
scene.appState.activeTool.type === "image"
|
||||
? { ...scene.appState.activeTool, type: "selection" }
|
||||
@ -1167,11 +1156,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
// defer so that the commitToHistory flag isn't reset via current update
|
||||
setTimeout(() => {
|
||||
// execute only if the condition still holds when the deferred callback
|
||||
// executes (it can be scheduled multiple times depending on how
|
||||
// many times the component renders)
|
||||
this.state.editingLinearElement &&
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1574,17 +1559,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectGroupsForSelectedElements(
|
||||
{
|
||||
...this.state,
|
||||
// keep sidebar (presumably the library) open if it's docked and
|
||||
// can fit.
|
||||
//
|
||||
// Note, we should close the sidebar only if we're dropping items
|
||||
// from library, not when pasting from clipboard. Alas.
|
||||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.canDeviceFitSidebar &&
|
||||
this.state.isSidebarDocked
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
isLibraryOpen:
|
||||
this.state.isLibraryOpen && this.device.canDeviceFitSidebar
|
||||
? this.state.isLibraryMenuDocked
|
||||
: false,
|
||||
selectedElementIds: newElements.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
@ -1642,8 +1620,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// Collaboration
|
||||
|
||||
setAppState: React.Component<any, AppState>["setState"] = (state) => {
|
||||
this.setState(state);
|
||||
setAppState = (obj: any) => {
|
||||
this.setState(obj);
|
||||
};
|
||||
|
||||
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
|
||||
@ -1781,35 +1759,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({});
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns whether the menu was toggled on or off
|
||||
*/
|
||||
public toggleMenu = (
|
||||
type: "library" | "customSidebar",
|
||||
force?: boolean,
|
||||
): boolean => {
|
||||
if (type === "customSidebar" && !this.props.renderSidebar) {
|
||||
console.warn(
|
||||
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === "library" || type === "customSidebar") {
|
||||
let nextValue;
|
||||
if (force === undefined) {
|
||||
nextValue = this.state.openSidebar === type ? null : type;
|
||||
} else {
|
||||
nextValue = force ? type : null;
|
||||
}
|
||||
this.setState({ openSidebar: nextValue });
|
||||
|
||||
return !!nextValue;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private updateCurrentCursorPosition = withBatchedUpdates(
|
||||
(event: MouseEvent) => {
|
||||
cursorX = event.clientX;
|
||||
@ -1885,7 +1834,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (event.code === CODES.ZERO) {
|
||||
const nextState = this.toggleMenu("library");
|
||||
const nextState = !this.state.isLibraryOpen;
|
||||
this.setState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
@ -1957,6 +1907,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
this.scene,
|
||||
this.state,
|
||||
true,
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -2434,9 +2386,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(getFontString(fontString));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(fontString));
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
const newHeight = Math.max(container.height, minHeight);
|
||||
const newWidth = Math.max(container.width, minWidth);
|
||||
mutateElement(container, { height: newHeight, width: newWidth });
|
||||
sceneX = container.x + newWidth / 2;
|
||||
sceneY = container.y + newHeight / 2;
|
||||
@ -2536,6 +2487,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
this.scene,
|
||||
this.state,
|
||||
true,
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -3122,7 +3075,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
])
|
||||
) {
|
||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
this.state.selectedLinearElement,
|
||||
this.state.zoom,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
@ -4539,6 +4492,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? new LinearElementEditor(
|
||||
elementsWithinSelection[0],
|
||||
this.scene,
|
||||
this.state,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
@ -4803,6 +4757,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
draggingElement,
|
||||
this.scene,
|
||||
this.state,
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
@ -4870,6 +4825,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
hitElement,
|
||||
this.scene,
|
||||
this.state,
|
||||
),
|
||||
});
|
||||
}
|
||||
@ -4972,6 +4928,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? new LinearElementEditor(
|
||||
newSelectedElements[0],
|
||||
this.scene,
|
||||
this.state,
|
||||
)
|
||||
: prevState.selectedLinearElement,
|
||||
},
|
||||
@ -5000,7 +4957,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
|
||||
// Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
|
||||
prevState.selectedLinearElement?.elementId !== hitElement.id
|
||||
? new LinearElementEditor(hitElement, this.scene)
|
||||
? new LinearElementEditor(
|
||||
hitElement,
|
||||
this.scene,
|
||||
this.state,
|
||||
)
|
||||
: prevState.selectedLinearElement,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
@ -5760,7 +5721,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...this.state,
|
||||
selectedElementIds: { [element.id]: true },
|
||||
selectedLinearElement: isLinearElement(element)
|
||||
? new LinearElementEditor(element, this.scene)
|
||||
? new LinearElementEditor(element, this.scene, this.state)
|
||||
: null,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
@ -5924,12 +5885,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const mayBeAllowToggleLineEditing =
|
||||
actionToggleLinearEditor.contextItemPredicate(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const separator = "separator";
|
||||
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
@ -5979,7 +5934,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
(this.device.isTouchScreen || isDarwin) &&
|
||||
this.device.isMobile &&
|
||||
navigator.clipboard && {
|
||||
trackEvent: false,
|
||||
name: "paste",
|
||||
@ -5991,9 +5946,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
(this.device.isTouchScreen || isDarwin) &&
|
||||
navigator.clipboard &&
|
||||
separator,
|
||||
this.device.isMobile && navigator.clipboard && separator,
|
||||
probablySupportsClipboardBlob &&
|
||||
elements.length > 0 &&
|
||||
actionCopyAsPng,
|
||||
@ -6038,11 +5991,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
(this.device.isTouchScreen || isDarwin) && actionCut,
|
||||
(this.device.isTouchScreen || isDarwin) &&
|
||||
navigator.clipboard &&
|
||||
actionCopy,
|
||||
(this.device.isTouchScreen || isDarwin) &&
|
||||
this.device.isMobile && actionCut,
|
||||
this.device.isMobile && navigator.clipboard && actionCopy,
|
||||
this.device.isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
trackEvent: false,
|
||||
@ -6054,7 +6005,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
(this.device.isTouchScreen || isDarwin) && separator,
|
||||
this.device.isMobile && separator,
|
||||
...options,
|
||||
separator,
|
||||
actionCopyStyles,
|
||||
@ -6075,7 +6026,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
maybeFlipHorizontal && actionFlipHorizontal,
|
||||
maybeFlipVertical && actionFlipVertical,
|
||||
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
||||
mayBeAllowToggleLineEditing && actionToggleLinearEditor,
|
||||
actionLink.contextItemPredicate(elements, this.state) && actionLink,
|
||||
actionDuplicateSelection,
|
||||
actionToggleLock,
|
||||
@ -6103,7 +6053,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// note that event.ctrlKey is necessary to handle pinch zooming
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
const sign = Math.sign(deltaY);
|
||||
const MAX_STEP = ZOOM_STEP * 100;
|
||||
const MAX_STEP = 10;
|
||||
const absDelta = Math.abs(deltaY);
|
||||
let delta = deltaY;
|
||||
if (absDelta > MAX_STEP) {
|
||||
|
@ -1,12 +1,20 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
setAppState,
|
||||
actionManager,
|
||||
showThemeBtn,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
showThemeBtn: boolean;
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{actionManager.renderAction("toggleTheme")}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
</div>
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState, Device } from "../types";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
@ -17,19 +17,13 @@ interface HintViewerProps {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
device: Device;
|
||||
}
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -117,13 +111,11 @@ export const HintViewer = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
});
|
||||
if (!hint) {
|
||||
return null;
|
||||
|
@ -2,12 +2,10 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export const InitializeApp = (props: Props) => {
|
||||
@ -23,5 +21,5 @@ export const InitializeApp = (props: Props) => {
|
||||
updateLang();
|
||||
}, [props.langCode]);
|
||||
|
||||
return loading ? <LoadingMessage theme={props.theme} /> : props.children;
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
|
@ -1,6 +1,48 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__wrapper.animate {
|
||||
transition: width 0.1s ease-in-out;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
@ -26,7 +26,7 @@ import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@ -40,9 +40,6 @@ import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./Footer";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -56,12 +53,12 @@ interface LayerUIProps {
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
showThemeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
@ -81,11 +78,11 @@ const LayerUI = ({
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
showThemeBtn,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
@ -212,7 +209,12 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
@ -247,6 +249,41 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ isLibraryOpen: false });
|
||||
}, [setAppState]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
files={files}
|
||||
id={id}
|
||||
appState={appState}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
appState,
|
||||
@ -300,7 +337,6 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
device={device}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
@ -345,23 +381,6 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
return appState.openSidebar === "customSidebar" ? (
|
||||
renderCustomSidebar?.() || null
|
||||
) : appState.openSidebar === "library" ? (
|
||||
<LibraryMenu
|
||||
appState={appState}
|
||||
onInsertElements={onInsertElements}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
|
||||
return (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
@ -395,6 +414,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
@ -404,11 +424,10 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -423,9 +442,8 @@ const LayerUI = ({
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
((appState.openSidebar === "library" &&
|
||||
appState.isSidebarDocked) ||
|
||||
hostSidebarCounters.docked) &&
|
||||
appState.isLibraryOpen &&
|
||||
appState.isLibraryMenuDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
@ -462,7 +480,9 @@ const LayerUI = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{renderSidebars()}
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@ -482,12 +502,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||
prev.renderCustomStats === next.renderCustomStats &&
|
||||
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
|
@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? "library" : null });
|
||||
const nextState = event.target.checked;
|
||||
setAppState({ isLibraryOpen: nextState });
|
||||
// track only openings
|
||||
if (isOpen) {
|
||||
if (nextState) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{
|
||||
);
|
||||
}
|
||||
}}
|
||||
checked={appState.openSidebar === "library"}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
|
@ -1,16 +1,10 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-ui__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
@ -29,100 +23,16 @@
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.layer-ui__library {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 1px;
|
||||
font-size: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
background-color: $oc-red-7;
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
&--export {
|
||||
background-color: $oc-lime-5;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-lime-7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $oc-lime-8;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-lime-5;
|
||||
}
|
||||
}
|
||||
|
||||
&--publish {
|
||||
background-color: $oc-cyan-6;
|
||||
&:hover {
|
||||
background-color: $oc-cyan-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-cyan-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
label {
|
||||
margin-left: -0.2em;
|
||||
margin-right: 1.1em;
|
||||
color: $oc-white;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-cyan-6;
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
background-color: $oc-blue-6;
|
||||
&:hover {
|
||||
background-color: $oc-blue-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-blue-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
|
@ -6,31 +6,30 @@ import {
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
} from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { Island } from "./Island";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT, VERSIONS } from "../constants";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||
import { useDevice } from "./App";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@ -60,45 +59,110 @@ const useOnClickOutside = (
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="layer-ui__library">
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</div>
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing so that LibraryButton
|
||||
// can toggle library menu
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
@ -124,12 +188,60 @@ export const LibraryMenuContent = ({
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.setLibrary(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
@ -139,168 +251,51 @@ export const LibraryMenuContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
onSelectItems={(ids) => setSelectedItems(ids)}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${
|
||||
appState.theme
|
||||
}&version=${VERSIONS.excalidrawLibrary}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryMenu: React.FC<{
|
||||
appState: AppState;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}> = ({
|
||||
appState,
|
||||
onInsertElements,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const device = useDevice();
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ openSidebar: null });
|
||||
}, [setAppState]);
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing so that LibraryButton
|
||||
// can toggle library menu
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
__isInternal
|
||||
// necessary to remount when switching between internal
|
||||
// and custom (host app) sidebar, so that the `props.onClose`
|
||||
// is colled correctly
|
||||
key="library"
|
||||
className="layer-ui__library-sidebar"
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Sidebar.Header className="layer-ui__library-header">
|
||||
<LibraryMenuHeader
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
library={library}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</Sidebar.Header>
|
||||
<LibraryMenuContent
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
id={id}
|
||||
appState={appState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
@ -1,258 +0,0 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenuHeader: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
library: Library;
|
||||
onRemoveFromLibrary: () => void;
|
||||
resetLibrary: () => void;
|
||||
onSelectItems: (items: LibraryItem["id"][]) => void;
|
||||
appState: AppState;
|
||||
}> = ({
|
||||
setAppState,
|
||||
selectedItems,
|
||||
library,
|
||||
onRemoveFromLibrary,
|
||||
resetLibrary,
|
||||
onSelectItems,
|
||||
appState,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItemsData.libraryItems.filter((item) =>
|
||||
selectedItems.includes(item.id),
|
||||
)
|
||||
: libraryItemsData.libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.setLibrary(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
const onLibraryImport = async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
};
|
||||
|
||||
const onLibraryExport = async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.getLatestLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
{!itemsSelected && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={onLibraryImport}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={onLibraryExport}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={() => setShowPublishLibraryDialog(true)}
|
||||
>
|
||||
<label>{t("buttons.publishLibrary")}</label>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,7 +5,96 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.library-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
align-items: center;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 1px;
|
||||
font-size: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
background-color: $oc-red-7;
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
&--export {
|
||||
background-color: $oc-lime-5;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-lime-7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $oc-lime-8;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-lime-5;
|
||||
}
|
||||
}
|
||||
|
||||
&--publish {
|
||||
background-color: $oc-cyan-6;
|
||||
&:hover {
|
||||
background-color: $oc-cyan-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-cyan-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
label {
|
||||
margin-left: -0.2em;
|
||||
margin-right: 1.1em;
|
||||
color: $oc-white;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-cyan-6;
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
background-color: $oc-blue-6;
|
||||
&:hover {
|
||||
background-color: $oc-blue-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-blue-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
@ -1,35 +1,225 @@
|
||||
import React, { useState } from "react";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { LibraryItem, LibraryItems } from "../types";
|
||||
import { arrayToMap, chunk } from "../utils";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { arrayToMap, chunk, muteFSAbortError } from "../utils";
|
||||
import { useDevice } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
const CELLS_PER_ROW = 4;
|
||||
import { SidebarLockButton } from "./SidebarLockButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
appState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: AppState;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
const device = useDevice();
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||
: libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{!itemsSelected && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await library.updateLibrary({
|
||||
libraryItems: fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
*/
|
||||
}),
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return;
|
||||
}
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
}
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.getLatestLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<div className="library-menu-browse-button--mobile">
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
@ -106,6 +296,7 @@ const LibraryMenuItems = ({
|
||||
<Stack.Col key={params.key}>
|
||||
<LibraryUnit
|
||||
elements={params.item?.elements}
|
||||
files={files}
|
||||
isPending={!params.item?.id && !!params.item?.elements}
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
@ -181,21 +372,56 @@ const LibraryMenuItems = ({
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
publishedItems.length || unpublishedItems.length
|
||||
? {
|
||||
flex: "1 1 0",
|
||||
overflowY: "auto",
|
||||
}
|
||||
: {
|
||||
marginBottom: "2rem",
|
||||
flex: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
const renderLibraryHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
{device.canDeviceFitSidebar && (
|
||||
<>
|
||||
<div className="layer-ui__sidebar-lock-button">
|
||||
<SidebarLockButton
|
||||
checked={appState.isLibraryMenuDocked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
const nextState = !appState.isLibraryMenuDocked;
|
||||
setAppState({
|
||||
isLibraryMenuDocked: nextState,
|
||||
});
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!device.isMobile && (
|
||||
<div className="ToolIcon__icon__close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={() =>
|
||||
setAppState({
|
||||
isLibraryOpen: false,
|
||||
})
|
||||
}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryMenuItems = () => {
|
||||
return (
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
@ -267,8 +493,8 @@ const LibraryMenuItems = ({
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0) && (
|
||||
(!device.isMobile &&
|
||||
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
|
||||
<div className="separator">{t("labels.excalidrawLib")}</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
@ -290,6 +516,41 @@ const LibraryMenuItems = ({
|
||||
) : null}
|
||||
</>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibraryFooter = () => {
|
||||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
style={
|
||||
device.isMobile
|
||||
? {
|
||||
minHeight: "200px",
|
||||
maxHeight: "70vh",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{renderLibraryHeader()}
|
||||
{renderLibraryMenuItems()}
|
||||
{!device.isMobile && renderLibraryFooter()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
@ -23,6 +23,7 @@ const PLUS_ICON = (
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
elements,
|
||||
files,
|
||||
isPending,
|
||||
onClick,
|
||||
selected,
|
||||
@ -31,6 +32,7 @@ export const LibraryUnit = ({
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
files: BinaryFiles;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
@ -54,7 +56,7 @@ export const LibraryUnit = ({
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null,
|
||||
files,
|
||||
);
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
@ -62,7 +64,7 @@ export const LibraryUnit = ({
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [elements]);
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().isMobile;
|
||||
|
@ -1,14 +1,8 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
import clsx from "clsx";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
delay,
|
||||
theme,
|
||||
}) => {
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
@ -26,11 +20,7 @@ export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("LoadingMessage", {
|
||||
"LoadingMessage--dark": theme === THEME.DARK,
|
||||
})}
|
||||
>
|
||||
<div className="LoadingMessage">
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@ -28,6 +28,7 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
@ -37,19 +38,19 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
showThemeBtn: boolean;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
@ -60,11 +61,10 @@ export const MobileMenu = ({
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@ -109,15 +109,11 @@ export const MobileMenu = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={true}
|
||||
device={device}
|
||||
/>
|
||||
<HintViewer appState={appState} elements={elements} isMobile={true} />
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
@ -175,13 +171,19 @@ export const MobileMenu = ({
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />}
|
||||
{
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{!appState.openMenu && appState.showStats && (
|
||||
<Stats
|
||||
@ -237,7 +239,7 @@ export const MobileMenu = ({
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
appState.openSidebar !== "library" && (
|
||||
!appState.isLibraryOpen && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
|
@ -1,89 +0,0 @@
|
||||
@import "open-color/open-color";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar {
|
||||
position: absolute;
|
||||
top: var(--sat);
|
||||
bottom: var(--sab);
|
||||
right: var(--sar);
|
||||
z-index: 5;
|
||||
|
||||
box-shadow: var(--shadow-island);
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin: var(--space-factor);
|
||||
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
|
||||
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0 15px 0;
|
||||
&:empty {
|
||||
margin: 0;
|
||||
}
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.layer-ui__sidebar-dock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
|
||||
.ToolIcon_type_floating .ToolIcon__icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,355 +0,0 @@
|
||||
import React from "react";
|
||||
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
queryAllByTestId,
|
||||
queryByTestId,
|
||||
render,
|
||||
waitFor,
|
||||
withExcalidrawDimensions,
|
||||
} from "../../tests/test-utils";
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render custom sidebar", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<Sidebar.Header>
|
||||
<div id="test-sidebar-header-content">42</div>
|
||||
</Sidebar.Header>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-header-content");
|
||||
expect(node).not.toBe(null);
|
||||
// make sure we don't render the default fallback header,
|
||||
// just the custom one
|
||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one sidebar and prefer the custom one", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// make sure the custom sidebar is rendered
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should always render custom sidebar with close button & close on click", async () => {
|
||||
const onClose = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" onClose={onClose}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||
expect(closeButton).not.toBe(null);
|
||||
|
||||
fireEvent.click(closeButton!.querySelector("button")!);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar">hello</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
// should show dock button when the sidebar fits to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
|
||||
// should not show dock button when the sidebar does not fit to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDockable: (dockable: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [dockable, setDockable] = React.useState(false);
|
||||
_setDockable = setDockable;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar
|
||||
className="test-sidebar"
|
||||
docked={false}
|
||||
dockable={dockable}
|
||||
>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
|
||||
// should show dock button when `dockable` is `true`, even if `docked`
|
||||
// prop is set
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDocked: (docked?: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [docked, setDocked] = React.useState<boolean | undefined>();
|
||||
_setDocked = setDocked;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" docked={docked}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const { h } = window;
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
const dockButton = await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(dockBotton).not.toBe(null);
|
||||
return dockBotton!;
|
||||
});
|
||||
|
||||
const dockButtonInput = dockButton.querySelector("input")!;
|
||||
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
// shouldn't update `appState.isSidebarDocked` when the sidebar
|
||||
// is controlled (`docked` prop is set), as host apps should handle
|
||||
// the state themselves
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
// the `appState.isSidebarDocked` should remain untouched when
|
||||
// `props.docked` is set to `false`, and user toggles
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(false);
|
||||
h.setState({ isSidebarDocked: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle sidebar using props.toggleMenu()", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
// sidebar isn't rendered initially
|
||||
// -------------------------------------------------------------------------
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
// toggle sidebar off
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// force-toggle sidebar off (=> still hidden)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
});
|
||||
|
||||
// force-toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
// toggle library (= hide custom sidebar)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("library")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,139 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { Island } from ".././Island";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import {
|
||||
SidebarPropsContext,
|
||||
SidebarProps,
|
||||
SidebarPropsContextValue,
|
||||
} from "./common";
|
||||
|
||||
import { SidebarHeaderComponents } from "./SidebarHeader";
|
||||
|
||||
import "./Sidebar.scss";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { updateObject } from "../../utils";
|
||||
|
||||
/** using a counter instead of boolean to handle race conditions where
|
||||
* the host app may render (mount/unmount) multiple different sidebar */
|
||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||
|
||||
export const Sidebar = Object.assign(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onClose,
|
||||
onDock,
|
||||
docked,
|
||||
dockable = true,
|
||||
className,
|
||||
__isInternal,
|
||||
}: SidebarProps<{
|
||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||
// It indicates that this sidebar should have lower precedence over host
|
||||
// sidebars, if both are open.
|
||||
/** @private internal */
|
||||
__isInternal?: boolean;
|
||||
}>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
||||
hostSidebarCountersAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (docked === undefined) {
|
||||
// ugly hack to get initial state out of AppState without subscribing
|
||||
// to it as a whole (once we have granular subscriptions, we'll move
|
||||
// to that)
|
||||
//
|
||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||
setAppState((state) => {
|
||||
setIsDockedFallback(state.isSidebarDocked);
|
||||
// bail from update
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}, [setAppState, docked]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!__isInternal) {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered + 1,
|
||||
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
||||
}));
|
||||
return () => {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered - 1,
|
||||
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
||||
}));
|
||||
};
|
||||
}
|
||||
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
||||
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onCloseRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
||||
headerPropsRef.current.onClose = () => {
|
||||
setAppState({ openSidebar: null });
|
||||
};
|
||||
headerPropsRef.current.onDock = (isDocked) => {
|
||||
if (docked === undefined) {
|
||||
setAppState({ isSidebarDocked: isDocked });
|
||||
setIsDockedFallback(isDocked);
|
||||
}
|
||||
onDock?.(isDocked);
|
||||
};
|
||||
// renew the ref object if the following props change since we want to
|
||||
// rerender. We can't pass down as component props manually because
|
||||
// the <Sidebar.Header/> can be rendered upsream.
|
||||
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||
docked: docked ?? isDockedFallback,
|
||||
dockable,
|
||||
});
|
||||
|
||||
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Island
|
||||
padding={2}
|
||||
className={clsx("layer-ui__sidebar", className)}
|
||||
ref={ref}
|
||||
>
|
||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||
<SidebarHeaderComponents.Context>
|
||||
<SidebarHeaderComponents.Component __isFallback />
|
||||
{children}
|
||||
</SidebarHeaderComponents.Context>
|
||||
</SidebarPropsContext.Provider>
|
||||
</Island>
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Header: SidebarHeaderComponents.Component,
|
||||
},
|
||||
);
|
@ -1,95 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useContext } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { useDevice } from "../App";
|
||||
import { SidebarPropsContext } from "./common";
|
||||
import { close } from "../icons";
|
||||
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarDockButton = (props: {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_medium`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const _SidebarHeader: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
const device = useDevice();
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
||||
const renderCloseButton = !!props.onClose;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("layer-ui__sidebar__header", className)}
|
||||
data-testid="sidebar-header"
|
||||
>
|
||||
{children}
|
||||
{(renderDockButton || renderCloseButton) && (
|
||||
<div className="layer-ui__sidebar__header__buttons">
|
||||
{renderDockButton && (
|
||||
<SidebarDockButton
|
||||
checked={!!props.docked}
|
||||
onChange={() => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.add("animate");
|
||||
|
||||
props.onDock?.(!props.docked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderCloseButton && (
|
||||
<div className="ToolIcon__icon__close" data-testid="sidebar-close">
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={props.onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
||||
|
||||
/** @private */
|
||||
export const SidebarHeaderComponents = { Context, Component };
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export type SidebarProps<P = {}> = {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Called on sidebar close (either by user action or by the editor).
|
||||
*/
|
||||
onClose?: () => void | boolean;
|
||||
/** if not supplied, sidebar won't be dockable */
|
||||
onDock?: (docked: boolean) => void;
|
||||
docked?: boolean;
|
||||
dockable?: boolean;
|
||||
className?: string;
|
||||
} & P;
|
||||
|
||||
export type SidebarPropsContextValue = Pick<
|
||||
SidebarProps,
|
||||
"onClose" | "onDock" | "docked" | "dockable"
|
||||
>;
|
||||
|
||||
export const SidebarPropsContext =
|
||||
React.createContext<SidebarPropsContextValue>({});
|
22
src/components/SidebarLockButton.scss
Normal file
22
src/components/SidebarLockButton.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__sidebar-lock-button {
|
||||
@include toolbarButtonColorStates;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
.ToolIcon_type_floating .side_lock_icon {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
svg {
|
||||
// mirror
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon_type_checkbox {
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
46
src/components/SidebarLockButton.tsx
Normal file
46
src/components/SidebarLockButton.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./SidebarLockButton.scss";
|
||||
|
||||
type SidebarLockIconProps = {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const SIDE_LIBRARY_TOGGLE_ICON = (
|
||||
<svg viewBox="0 0 24 24" fill="#ffffff">
|
||||
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SidebarLockButton = (props: SidebarLockIconProps) => {
|
||||
return (
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
|
||||
{SIDE_LIBRARY_TOGGLE_ICON}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@ -1,63 +0,0 @@
|
||||
import React, {
|
||||
useMemo,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
createContext,
|
||||
} from "react";
|
||||
|
||||
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
|
||||
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
||||
|
||||
const DefaultComponentContext = createContext<ContextValue>([
|
||||
false,
|
||||
() => {},
|
||||
]);
|
||||
|
||||
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
|
||||
const contextValue: ContextValue = useMemo(
|
||||
() => [isRenderedUpstream, setIsRenderedUpstream],
|
||||
[isRenderedUpstream],
|
||||
);
|
||||
|
||||
return (
|
||||
<DefaultComponentContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DefaultComponentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultComponent = (
|
||||
props: P & {
|
||||
// indicates whether component should render when not rendered upstream
|
||||
/** @private internal */
|
||||
__isFallback?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
|
||||
DefaultComponentContext,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.__isFallback) {
|
||||
setIsRenderedUpstream(true);
|
||||
return () => setIsRenderedUpstream(false);
|
||||
}
|
||||
}, [props.__isFallback, setIsRenderedUpstream]);
|
||||
|
||||
if (props.__isFallback && isRenderedUpstream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
if (Component.name) {
|
||||
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
|
||||
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
|
||||
}
|
||||
|
||||
return [ComponentContext, DefaultComponent] as const;
|
||||
};
|
@ -99,9 +99,6 @@ export const MIME_TYPES = {
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
binary: "application/octet-stream",
|
||||
} as const;
|
||||
|
||||
@ -122,7 +119,6 @@ export const TITLE_TIMEOUT = 10000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
export const MIN_ZOOM = 0.1;
|
||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@ -153,7 +149,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
export: { saveFileToDisk: true },
|
||||
loadScene: true,
|
||||
saveToActiveFile: true,
|
||||
toggleTheme: null,
|
||||
theme: true,
|
||||
saveAsImage: true,
|
||||
},
|
||||
};
|
||||
@ -184,9 +180,6 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
MIME_TYPES.webp,
|
||||
MIME_TYPES.bmp,
|
||||
MIME_TYPES.ico,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
@ -208,18 +201,8 @@ export const VERTICAL_ALIGN = {
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const TEXT_ALIGN = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
@ -1,5 +1,3 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
@ -32,8 +30,3 @@
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingMessage--dark {
|
||||
background-color: #121212;
|
||||
color: #ced4da;
|
||||
}
|
||||
|
@ -148,7 +148,7 @@ class Library {
|
||||
defaultStatus?: "unpublished" | "published";
|
||||
}): Promise<LibraryItems> => {
|
||||
if (openLibraryMenu) {
|
||||
this.app.setState({ openSidebar: "library" });
|
||||
this.app.setState({ isLibraryOpen: true });
|
||||
}
|
||||
|
||||
return this.setLibrary(() => {
|
||||
@ -365,56 +365,38 @@ export const useHandleLibrary = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const importLibraryFromURL = async ({
|
||||
const importLibraryFromURL = ({
|
||||
libraryUrl,
|
||||
idToken,
|
||||
}: {
|
||||
libraryUrl: string;
|
||||
idToken: string | null;
|
||||
}) => {
|
||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||
const blob = await request.blob();
|
||||
resolve(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
const shouldPrompt = idToken !== excalidrawAPI.id;
|
||||
|
||||
// wait for the tab to be focused before continuing in case we'll prompt
|
||||
// for confirmation
|
||||
await (shouldPrompt && document.hidden
|
||||
? new Promise<void>((resolve) => {
|
||||
window.addEventListener("focus", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
})
|
||||
: null);
|
||||
|
||||
try {
|
||||
await excalidrawAPI.updateLibrary({
|
||||
libraryItems: libraryPromise,
|
||||
prompt: shouldPrompt,
|
||||
merge: true,
|
||||
defaultStatus: "published",
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||
}
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||
}
|
||||
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||
const blob = await request.blob();
|
||||
resolve(blob);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
}),
|
||||
prompt: idToken !== excalidrawAPI.id,
|
||||
merge: true,
|
||||
defaultStatus: "published",
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
};
|
||||
const onHashChange = (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
|
@ -9,12 +9,11 @@ import {
|
||||
LibraryItem,
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState, LegacyAppState } from "./types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
refreshTextDimensions,
|
||||
} from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
@ -22,7 +21,6 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
@ -73,8 +71,6 @@ const restoreElementWithProperties = <
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
/** metadata that may be present in elements during collaboration */
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
},
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
@ -87,9 +83,7 @@ const restoreElementWithProperties = <
|
||||
> &
|
||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
|
||||
): T => {
|
||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
} = {
|
||||
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||
type: extra.type || element.type,
|
||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||
// newly added elements
|
||||
@ -126,10 +120,6 @@ const restoreElementWithProperties = <
|
||||
base.customData = element.customData;
|
||||
}
|
||||
|
||||
if (PRECEDING_ELEMENT_KEY in element) {
|
||||
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
@ -139,7 +129,6 @@ const restoreElementWithProperties = <
|
||||
|
||||
const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
refreshDimensions = true,
|
||||
): typeof element | null => {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
@ -152,7 +141,7 @@ const restoreElement = (
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
element = restoreElementWithProperties(element, {
|
||||
return restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
@ -162,11 +151,6 @@ const restoreElement = (
|
||||
containerId: element.containerId ?? null,
|
||||
originalText: element.originalText || element.text,
|
||||
});
|
||||
|
||||
if (refreshDimensions) {
|
||||
element = { ...element, ...refreshTextDimensions(element) };
|
||||
}
|
||||
return element;
|
||||
case "freedraw": {
|
||||
return restoreElementWithProperties(element, {
|
||||
points: element.points,
|
||||
@ -239,17 +223,13 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
refreshDimensions = true,
|
||||
): ExcalidrawElement[] => {
|
||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||
return (elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(
|
||||
element,
|
||||
refreshDimensions,
|
||||
);
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
@ -262,43 +242,6 @@ export const restoreElements = (
|
||||
}, [] as ExcalidrawElement[]);
|
||||
};
|
||||
|
||||
const coalesceAppStateValue = <
|
||||
T extends keyof ReturnType<typeof getDefaultAppState>,
|
||||
>(
|
||||
key: T,
|
||||
appState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => {
|
||||
const value = appState[key];
|
||||
// NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
|
||||
return value !== undefined ? value! : defaultAppState[key];
|
||||
};
|
||||
|
||||
const LegacyAppStateMigrations: {
|
||||
[K in keyof LegacyAppState]: (
|
||||
ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
|
||||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||
} = {
|
||||
isLibraryOpen: (appState, defaultAppState) => {
|
||||
return [
|
||||
"openSidebar",
|
||||
"isLibraryOpen" in appState
|
||||
? appState.isLibraryOpen
|
||||
? "library"
|
||||
: null
|
||||
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
isLibraryMenuDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"isSidebarDocked",
|
||||
appState.isLibraryMenuDocked ??
|
||||
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const restoreAppState = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
@ -306,30 +249,11 @@ export const restoreAppState = (
|
||||
appState = appState || {};
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const nextAppState = {} as typeof defaultAppState;
|
||||
|
||||
// first, migrate all legacy AppState properties to new ones. We do it
|
||||
// in one go before migrate the rest of the properties in case the new ones
|
||||
// depend on checking any other key (i.e. they are coupled)
|
||||
for (const legacyKey of Object.keys(
|
||||
LegacyAppStateMigrations,
|
||||
) as (keyof typeof LegacyAppStateMigrations)[]) {
|
||||
if (legacyKey in appState) {
|
||||
const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
|
||||
appState,
|
||||
defaultAppState,
|
||||
);
|
||||
(nextAppState as any)[nextKey] = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
][]) {
|
||||
// if AppState contains a legacy key, prefer that one and migrate its
|
||||
// value to the new one
|
||||
const suppliedValue = appState[key];
|
||||
|
||||
const localValue = localAppState ? localAppState[key] : undefined;
|
||||
(nextAppState as any)[key] =
|
||||
suppliedValue !== undefined
|
||||
@ -366,12 +290,9 @@ export const restoreAppState = (
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
nextAppState.openSidebar === "library"
|
||||
? nextAppState.isSidebarDocked
|
||||
? "library"
|
||||
: null
|
||||
: nextAppState.openSidebar,
|
||||
isLibraryOpen: nextAppState.isLibraryMenuDocked
|
||||
? nextAppState.isLibraryOpen
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
||||
@ -387,7 +308,7 @@ export const restore = (
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, true),
|
||||
elements: restoreElements(data?.elements, localElements),
|
||||
appState: restoreAppState(data?.appState, localAppState || null),
|
||||
files: data?.files || {},
|
||||
};
|
||||
|
@ -17,32 +17,12 @@ export interface ExportedDataState {
|
||||
files: BinaryFiles | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of legacy AppState keys, with values of:
|
||||
* [<legacy type>, <new AppState proeprty>]
|
||||
*
|
||||
* This is a helper type used in downstream abstractions.
|
||||
* Don't consume on its own.
|
||||
*/
|
||||
export type LegacyAppState = {
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryOpen: [boolean, "openSidebar"];
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
|
||||
};
|
||||
|
||||
export interface ImportedDataState {
|
||||
type?: string;
|
||||
version?: number;
|
||||
source?: string;
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<
|
||||
Partial<
|
||||
AppState & {
|
||||
[T in keyof LegacyAppState]: LegacyAppState[T][0];
|
||||
}
|
||||
>
|
||||
> | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems_anyVersion;
|
||||
files?: BinaryFiles;
|
||||
|
@ -22,7 +22,7 @@ const _ce = ({
|
||||
backgroundColor: "#000",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
roughness: 0,
|
||||
roughness: 1,
|
||||
opacity: 1,
|
||||
x,
|
||||
y,
|
||||
@ -106,7 +106,7 @@ describe("getElementBounds", () => {
|
||||
} as ExcalidrawLinearElement);
|
||||
expect(x1).toEqual(360.3176068760539);
|
||||
expect(y1).toEqual(185.90654264413516);
|
||||
expect(x2).toEqual(480.87005902729743);
|
||||
expect(y2).toEqual(320.4751269334226);
|
||||
expect(x2).toEqual(473.8171188951176);
|
||||
expect(y2).toEqual(320.391865303557);
|
||||
});
|
||||
});
|
||||
|
@ -387,49 +387,34 @@ export const getArrowheadPoints = (
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
const generator = rough.generator();
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
const method = (() => {
|
||||
if (element.strokeSharpness !== "sharp") {
|
||||
return "curve";
|
||||
}
|
||||
if (options.fill) {
|
||||
return "polygon";
|
||||
}
|
||||
return "linearPath";
|
||||
})();
|
||||
|
||||
return generator[method](element.points as Mutable<Point>[], options);
|
||||
};
|
||||
|
||||
const getLinearElementRotatedBounds = (
|
||||
element: ExcalidrawLinearElement,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): [number, number, number, number] => {
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = rotate(
|
||||
element.x + pointX,
|
||||
element.y + pointY,
|
||||
cx,
|
||||
cy,
|
||||
element.angle,
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
(limits, [x, y]) => {
|
||||
[x, y] = rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
return limits;
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
return [x, y, x, y];
|
||||
return [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
const shape = getShapeForElement(element)!;
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = getShapeForElement(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const transformXY = (x: number, y: number) =>
|
||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
|
||||
return getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
};
|
||||
|
||||
@ -553,7 +538,6 @@ export const getResizedElementAbsoluteCoords = (
|
||||
points as [number, number][],
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
|
||||
const ops = getCurvePathOps(curve);
|
||||
bounds = getMinMaxXYFromCurvePathOps(ops);
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
|
@ -40,6 +40,11 @@ const editorMidPointsCache: {
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
|
||||
const visiblePointIndexesCache: {
|
||||
points: number[];
|
||||
zoom: number | null;
|
||||
isEditingLinearElement: boolean;
|
||||
} = { points: [], zoom: null, isEditingLinearElement: false };
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@ -65,7 +70,12 @@ export class LinearElementEditor {
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
editingLinearElement = false,
|
||||
) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
@ -433,7 +443,7 @@ export class LinearElementEditor {
|
||||
return null;
|
||||
}
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
appState.selectedLinearElement,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
@ -560,6 +570,59 @@ export class LinearElementEditor {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static getVisiblePointIndexes(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
): typeof visiblePointIndexesCache["points"] {
|
||||
const isEditingLinearElement = !!appState.editingLinearElement;
|
||||
if (appState.editingLinearElement) {
|
||||
// So that when we exit the editor the points are calculated again
|
||||
visiblePointIndexesCache.isEditingLinearElement = true;
|
||||
return element.points.map((_, index) => index);
|
||||
}
|
||||
|
||||
if (
|
||||
visiblePointIndexesCache.points &&
|
||||
visiblePointIndexesCache.zoom === appState.zoom.value &&
|
||||
isEditingLinearElement === visiblePointIndexesCache.isEditingLinearElement
|
||||
) {
|
||||
return visiblePointIndexesCache.points;
|
||||
}
|
||||
|
||||
LinearElementEditor.updateVisiblePointIndexesCache(element, appState);
|
||||
return visiblePointIndexesCache.points;
|
||||
}
|
||||
|
||||
static updateVisiblePointIndexesCache(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
) {
|
||||
const visiblePointIndexes: number[] = [];
|
||||
let previousPoint: Point | null = null;
|
||||
element.points.forEach((point, index) => {
|
||||
let distance = Infinity;
|
||||
if (previousPoint) {
|
||||
distance =
|
||||
distance2d(point[0], point[1], previousPoint[0], previousPoint[1]) *
|
||||
appState.zoom.value;
|
||||
}
|
||||
const isExtremePoint = index === 0 || index === element.points.length - 1;
|
||||
const threshold = 2 * LinearElementEditor.POINT_HANDLE_SIZE;
|
||||
if (isExtremePoint || distance >= threshold) {
|
||||
// hide n-1 point if distance is less than threshold
|
||||
if (isExtremePoint && distance < threshold) {
|
||||
visiblePointIndexes.pop();
|
||||
}
|
||||
visiblePointIndexes.push(index);
|
||||
previousPoint = point;
|
||||
}
|
||||
});
|
||||
visiblePointIndexesCache.points = visiblePointIndexes;
|
||||
visiblePointIndexesCache.zoom = appState.zoom.value;
|
||||
visiblePointIndexesCache.isEditingLinearElement =
|
||||
!!appState.editingLinearElement;
|
||||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
appState: AppState,
|
||||
@ -617,15 +680,6 @@ export class LinearElementEditor {
|
||||
|
||||
ret.didAddPoint = true;
|
||||
ret.isMidPoint = true;
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: element.points[1],
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
@ -662,7 +716,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
appState.selectedLinearElement,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
@ -725,7 +779,11 @@ export class LinearElementEditor {
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
if (ret.didAddPoint) {
|
||||
ret.linearElementEditor = {
|
||||
...ret.linearElementEditor,
|
||||
};
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
@ -873,25 +931,37 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
linearElementEditor: LinearElementEditor | null,
|
||||
zoom: AppState["zoom"],
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
if (!linearElementEditor) {
|
||||
return -1;
|
||||
}
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
);
|
||||
if (!element) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const pointHandles =
|
||||
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
let idx = pointHandles.length;
|
||||
let counter = visiblePointIndexesCache.points.length;
|
||||
|
||||
// loop from right to left because points on the right are rendered over
|
||||
// points on the left, thus should take precedence when clicking, if they
|
||||
// overlap
|
||||
while (--idx > -1) {
|
||||
const point = pointHandles[idx];
|
||||
while (--counter >= 0) {
|
||||
const index = visiblePointIndexesCache.points[counter];
|
||||
const point = pointHandles[index];
|
||||
if (
|
||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
return idx;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
|
@ -21,12 +21,7 @@ import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
@ -169,8 +164,7 @@ const getAdjustedDimensions = (
|
||||
let maxWidth = null;
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
const {
|
||||
width: nextWidth,
|
||||
@ -230,16 +224,15 @@ const getAdjustedDimensions = (
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
let height = container.height;
|
||||
let width = container.width;
|
||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
||||
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (height !== containerDims.height || width !== containerDims.width) {
|
||||
if (height !== container.height || width !== container.width) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
@ -252,33 +245,8 @@ const getAdjustedDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
export const refreshTextDimensions = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
text = textElement.text,
|
||||
) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (container) {
|
||||
// text = wrapText(text, getFontString(textElement), container.width);
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
element: ExcalidrawTextElement,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
@ -289,10 +257,16 @@ export const updateTextElement = (
|
||||
originalText: string;
|
||||
},
|
||||
): ExcalidrawTextElement => {
|
||||
return newElementWith(textElement, {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
text = wrapText(text, getFontString(element), container.width);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(element, text);
|
||||
return newElementWith(element, {
|
||||
text,
|
||||
originalText,
|
||||
isDeleted: isDeleted ?? textElement.isDeleted,
|
||||
...refreshTextDimensions(textElement, originalText),
|
||||
isDeleted: isDeleted ?? element.isDeleted,
|
||||
...dimensions,
|
||||
});
|
||||
};
|
||||
|
||||
@ -334,9 +308,6 @@ export const newLinearElement = (
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
@ -344,9 +315,9 @@ export const newImageElement = (
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
status: "pending",
|
||||
fileId: null,
|
||||
scale: [1, 1],
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -721,7 +721,7 @@ const resizeMultipleElements = (
|
||||
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||
) * (shouldResizeFromCenter ? 2 : 1);
|
||||
|
||||
if (scale === 0) {
|
||||
if (scale === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -766,26 +766,21 @@ const resizeMultipleElements = (
|
||||
width - optionalPadding,
|
||||
height - optionalPadding,
|
||||
);
|
||||
if (textMeasurements) {
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
}
|
||||
|
||||
if (!textMeasurements) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
};
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(element.latest, { newSize: { width, height } });
|
||||
|
||||
mutateElement(element.latest, update);
|
||||
|
||||
if (boundTextElement && boundTextUpdates) {
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { wrapText } from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
@ -46,7 +45,7 @@ up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
@ -94,7 +93,7 @@ whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
@ -133,7 +132,7 @@ break it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
|
@ -7,67 +7,53 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
element: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
let text = textElement.text;
|
||||
const maxWidth = container
|
||||
? container.width - BOUND_TEXT_PADDING * 2
|
||||
: undefined;
|
||||
let text = element.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
container.width,
|
||||
);
|
||||
}
|
||||
const metrics = measureText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
let coordY = textElement.y;
|
||||
let coordX = textElement.x;
|
||||
let coordY = element.y;
|
||||
let coordX = element.x;
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
let nextHeight = container.height;
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
BOUND_TEXT_PADDING;
|
||||
container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
coordY = container.y + container.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX =
|
||||
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordX = container.x + container.width / 2 - metrics.width / 2;
|
||||
}
|
||||
|
||||
mutateElement(container, { height: nextHeight });
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
mutateElement(element, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
@ -128,7 +114,6 @@ export const handleBindTextResize = (
|
||||
}
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
let containerHeight = element.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
@ -136,7 +121,7 @@ export const handleBindTextResize = (
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(element),
|
||||
element.width,
|
||||
);
|
||||
}
|
||||
|
||||
@ -146,7 +131,6 @@ export const handleBindTextResize = (
|
||||
element.width,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
@ -174,17 +158,13 @@ export const handleBindTextResize = (
|
||||
} else {
|
||||
updatedY = element.y + element.height / 2 - nextHeight / 2;
|
||||
}
|
||||
const updatedX =
|
||||
textElement.textAlign === TEXT_ALIGN.LEFT
|
||||
? element.x + BOUND_TEXT_PADDING
|
||||
: textElement.textAlign === TEXT_ALIGN.RIGHT
|
||||
? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
|
||||
: element.x + element.width / 2 - nextWidth / 2;
|
||||
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
// preserve padding and set width correctly
|
||||
width: element.width - BOUND_TEXT_PADDING * 2,
|
||||
height: nextHeight,
|
||||
x: updatedX,
|
||||
x: element.x + BOUND_TEXT_PADDING,
|
||||
y: updatedY,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
@ -211,6 +191,7 @@ export const measureText = (
|
||||
container.style.minHeight = "1em";
|
||||
if (maxWidth) {
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
container.style.width = `${String(maxWidth)}px`;
|
||||
container.style.maxWidth = `${String(maxWidth)}px`;
|
||||
container.style.overflow = "hidden";
|
||||
container.style.wordBreak = "break-word";
|
||||
@ -228,8 +209,7 @@ export const measureText = (
|
||||
container.appendChild(span);
|
||||
// Baseline is important for positioning text on canvas
|
||||
const baseline = span.offsetTop + span.offsetHeight;
|
||||
// Since span adds 1px extra width to the container
|
||||
const width = container.offsetWidth + 1;
|
||||
const width = container.offsetWidth;
|
||||
|
||||
const height = container.offsetHeight;
|
||||
document.body.removeChild(container);
|
||||
@ -267,7 +247,13 @@ const getTextWidth = (text: string, font: FontString) => {
|
||||
return metrics.width;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
containerWidth: number,
|
||||
) => {
|
||||
const maxWidth = containerWidth - BOUND_TEXT_PADDING * 2;
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getTextWidth(" ", font);
|
||||
@ -488,7 +474,3 @@ export const getContainerElement = (
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
@ -493,7 +492,9 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@ -694,8 +695,9 @@ describe("textWysiwyg", () => {
|
||||
// Edit and text by removing second line and it should
|
||||
// still vertically align correctly
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
@ -732,7 +734,9 @@ describe("textWysiwyg", () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@ -767,11 +771,12 @@ describe("textWysiwyg", () => {
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't bind to container if container has bound text", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@ -808,73 +813,5 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
expect(text.containerId).toBe(null);
|
||||
});
|
||||
|
||||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
109.5,
|
||||
17,
|
||||
]
|
||||
`);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
editor.select();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
editor.blur();
|
||||
|
||||
// should left align horizontally and bottom vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
90,
|
||||
]
|
||||
`);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
editor.select();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Right"));
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
editor.blur();
|
||||
|
||||
// should right align horizontally and top vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
424,
|
||||
-539,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -18,7 +18,6 @@ import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
@ -28,7 +27,6 @@ import {
|
||||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
|
||||
const normalizeText = (text: string) => {
|
||||
return (
|
||||
@ -85,17 +83,17 @@ export const textWysiwyg = ({
|
||||
app: App;
|
||||
}) => {
|
||||
const textPropertiesUpdated = (
|
||||
updatedTextElement: ExcalidrawTextElement,
|
||||
updatedElement: ExcalidrawTextElement,
|
||||
editable: HTMLTextAreaElement,
|
||||
) => {
|
||||
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||||
if (
|
||||
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||||
getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !==
|
||||
currentFont
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
|
||||
if (`${updatedElement.fontSize}px` !== editable.style.fontSize) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -104,73 +102,74 @@ export const textWysiwyg = ({
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedTextElement =
|
||||
const updatedElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
if (!updatedTextElement) {
|
||||
if (!updatedElement) {
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const { textAlign, verticalAlign } = updatedElement;
|
||||
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
const coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
const container = getContainerElement(updatedTextElement);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
||||
if (updatedElement && isTextElement(updatedElement)) {
|
||||
let coordX = updatedElement.x;
|
||||
let coordY = updatedElement.y;
|
||||
const container = getContainerElement(updatedElement);
|
||||
let maxWidth = updatedElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
const width = updatedTextElement.width;
|
||||
let maxHeight = updatedElement.height;
|
||||
let width = updatedElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedTextElement.height;
|
||||
if (container && updatedTextElement.containerId) {
|
||||
let height = updatedElement.height;
|
||||
if (container && updatedElement.containerId) {
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
updatedElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
height = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
originalContainerHeight = containerDims.height;
|
||||
originalContainerHeight = container.height;
|
||||
|
||||
// update height of the editor after properties updated
|
||||
height = updatedTextElement.height;
|
||||
height = updatedElement.height;
|
||||
}
|
||||
if (!originalContainerHeight) {
|
||||
originalContainerHeight = containerDims.height;
|
||||
originalContainerHeight = container.height;
|
||||
}
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = container.width - BOUND_TEXT_PADDING * 2;
|
||||
maxHeight = container.height - BOUND_TEXT_PADDING * 2;
|
||||
width = maxWidth;
|
||||
// The coordinates of text box set a distance of
|
||||
// 5px to preserve padding
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
// autogrow container height if text exceeds
|
||||
if (height > maxHeight) {
|
||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
mutateElement(container, { height: container.height + diff });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
containerDims.height > originalContainerHeight &&
|
||||
container.height > originalContainerHeight &&
|
||||
height < maxHeight
|
||||
) {
|
||||
const diff = Math.min(maxHeight - height, approxLineHeight);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
mutateElement(container, { height: container.height - diff });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||
coordY = container.y + container.height / 2 - height / 2;
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
|
||||
container.y + container.height - height - BOUND_TEXT_PADDING;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -178,7 +177,7 @@ export const textWysiwyg = ({
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
editable.value = updatedTextElement.originalText;
|
||||
editable.value = updatedElement.originalText;
|
||||
|
||||
// restore cursor position after value updated so it doesn't
|
||||
// go to the end of text when container auto expanded
|
||||
@ -193,10 +192,10 @@ export const textWysiwyg = ({
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
const lines = updatedElement.originalText.split("\n");
|
||||
const lineHeight = updatedElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
: updatedElement.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
}
|
||||
@ -204,12 +203,12 @@ export const textWysiwyg = ({
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
const angle = container ? container.angle : updatedTextElement.angle;
|
||||
const angle = container ? container.angle : updatedElement.angle;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
font: getFontString(updatedElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${Math.min(width, maxWidth)}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
@ -223,17 +222,18 @@ export const textWysiwyg = ({
|
||||
),
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
opacity: updatedTextElement.opacity / 100,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
maxWidth: `${maxWidth}px`,
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
if (isTestEnv()) {
|
||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||
editable.style.fontFamily = getFontFamilyString(updatedElement);
|
||||
}
|
||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||
mutateElement(updatedElement, { x: coordX, y: coordY });
|
||||
}
|
||||
};
|
||||
|
||||
@ -276,10 +276,10 @@ export const textWysiwyg = ({
|
||||
|
||||
if (onChange) {
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
const updatedElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
const font = getFontString(updatedElement);
|
||||
// using scrollHeight here since we need to calculate
|
||||
// number of lines so cannot use editable.style.height
|
||||
// as that gets updated below
|
||||
@ -297,14 +297,13 @@ export const textWysiwyg = ({
|
||||
// doubles the height as soon as user starts typing
|
||||
if (isBoundToContainer(element) && lines > 1) {
|
||||
let height = "auto";
|
||||
editable.style.height = "0px";
|
||||
let heightSet = false;
|
||||
|
||||
if (lines === 2) {
|
||||
const container = getContainerElement(element);
|
||||
const actualLineCount = wrapText(
|
||||
editable.value,
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
container!.width,
|
||||
).split("\n").length;
|
||||
// This is browser behaviour when setting height to "auto"
|
||||
// It sets the height needed for 2 lines even if actual
|
||||
@ -313,13 +312,10 @@ export const textWysiwyg = ({
|
||||
// so single line aligns vertically when deleting
|
||||
if (actualLineCount === 1) {
|
||||
height = `${editable.scrollHeight / 2}px`;
|
||||
editable.style.height = height;
|
||||
heightSet = true;
|
||||
}
|
||||
}
|
||||
if (!heightSet) {
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
editable.style.height = height;
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Point } from "../types";
|
||||
import { FONT_FAMILY, TEXT_ALIGN, THEME, VERTICAL_ALIGN } from "../constants";
|
||||
import { FONT_FAMILY, THEME, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
@ -11,7 +11,7 @@ export type GroupId = string;
|
||||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
export type StrokeSharpness = "round" | "sharp";
|
||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
|
||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||
|
@ -34,7 +34,6 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
} as const;
|
||||
|
@ -583,7 +583,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
remoteElements = restoreElements(remoteElements, null, false);
|
||||
remoteElements = restoreElements(remoteElements, null);
|
||||
|
||||
const reconciledElements = _reconcileElements(
|
||||
localElements,
|
||||
|
@ -18,7 +18,6 @@ import throttle from "lodash.throttle";
|
||||
import { newElementWith } from "../../element/mutateElement";
|
||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
||||
import { encryptData } from "../../data/encryption";
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
|
||||
class Portal {
|
||||
collab: TCollabClass;
|
||||
@ -153,7 +152,7 @@ class Portal {
|
||||
acc.push({
|
||||
...element,
|
||||
// z-index info for the reconciler
|
||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
parent: idx === 0 ? "^" : elements[idx - 1]?.id,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { PRECEDING_ELEMENT_KEY } from "../../constants";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
|
||||
@ -7,7 +6,7 @@ export type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
};
|
||||
|
||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
||||
[PRECEDING_ELEMENT_KEY]?: string;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
@ -72,8 +71,8 @@ export const reconcileElements = (
|
||||
const local = localElementsData[remoteElement.id];
|
||||
|
||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
if (remoteElement.parent) {
|
||||
delete remoteElement.parent;
|
||||
}
|
||||
|
||||
continue;
|
||||
@ -93,12 +92,10 @@ export const reconcileElements = (
|
||||
// parent may not be defined in case the remote client is running an older
|
||||
// excalidraw version
|
||||
const parent =
|
||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
||||
remoteElements[remoteElementIdx - 1]?.id ||
|
||||
null;
|
||||
remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null;
|
||||
|
||||
if (parent != null) {
|
||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
||||
delete remoteElement.parent;
|
||||
|
||||
// ^ indicates the element is the first in elements array
|
||||
if (parent === "^") {
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
APP_NAME,
|
||||
COOKIES,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
@ -18,7 +17,6 @@ import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
@ -514,21 +512,6 @@ const ExcalidrawWrapper = () => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
||||
// currently only used for body styling during init (see public/index.html),
|
||||
// but may change in the future
|
||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
||||
}, [theme]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@ -538,8 +521,6 @@ const ExcalidrawWrapper = () => {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
setTheme(appState.theme);
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
@ -729,7 +710,6 @@ const ExcalidrawWrapper = () => {
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
@ -759,7 +739,6 @@ const ExcalidrawWrapper = () => {
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
/>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
@ -24,7 +24,6 @@ const allLanguages: Language[] = [
|
||||
{ code: "fa-IR", label: "فارسی", rtl: true },
|
||||
{ code: "fi-FI", label: "Suomi" },
|
||||
{ code: "fr-FR", label: "Français" },
|
||||
{ code: "gl-ES ", label: "Galego" },
|
||||
{ code: "he-IL", label: "עברית", rtl: true },
|
||||
{ code: "hi-IN", label: "हिन्दी" },
|
||||
{ code: "hu-HU", label: "Magyar" },
|
||||
@ -34,7 +33,6 @@ const allLanguages: Language[] = [
|
||||
{ code: "kab-KAB", label: "Taqbaylit" },
|
||||
{ code: "kk-KZ", label: "Қазақ тілі" },
|
||||
{ code: "ko-KR", label: "한국어" },
|
||||
{ code: "ku-TR", label: "Kurdî" },
|
||||
{ code: "lt-LT", label: "Lietuvių" },
|
||||
{ code: "lv-LV", label: "Latviešu" },
|
||||
{ code: "my-MM", label: "Burmese" },
|
||||
|
@ -18,8 +18,11 @@ export const CODES = {
|
||||
SLASH: "Slash",
|
||||
C: "KeyC",
|
||||
D: "KeyD",
|
||||
G: "KeyG",
|
||||
F: "KeyF",
|
||||
H: "KeyH",
|
||||
V: "KeyV",
|
||||
X: "KeyX",
|
||||
Z: "KeyZ",
|
||||
R: "KeyR",
|
||||
} as const;
|
||||
@ -44,12 +47,9 @@ export const KEYS = {
|
||||
COMMA: ",",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
F: "f",
|
||||
G: "g",
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
O: "o",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "إنشاء رابط",
|
||||
"label": "رابط"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -1,279 +1,275 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "পেস্ট করুন",
|
||||
"pasteCharts": "চার্ট পেস্ট করুন",
|
||||
"selectAll": "সবটা সিলেক্ট করুন",
|
||||
"multiSelect": "একাধিক সিলেক্ট করুন",
|
||||
"moveCanvas": "ক্যানভাস সরান",
|
||||
"pasteCharts": "চার্টগুলো পেস্ট করুন",
|
||||
"selectAll": "সব সিলেক্ট করুন",
|
||||
"multiSelect": "সিলেকশনে এলিমেন্ট এ্যাড করুন",
|
||||
"moveCanvas": "ক্যানভাস মুভ করুন",
|
||||
"cut": "কাট করুন",
|
||||
"copy": "কপি করুন",
|
||||
"copyAsPng": "পীএনজী ছবির মতন কপি করুন",
|
||||
"copyAsSvg": "এসভীজী ছবির মতন কপি করুন",
|
||||
"copyText": "লিখিত তথ্যের মতন কপি করুন",
|
||||
"bringForward": "অধিকতর সামনে আনুন",
|
||||
"sendToBack": "অধিকতর পিছনে নিয়ে যান",
|
||||
"bringToFront": "সবার সামনে আনুন",
|
||||
"sendBackward": "সবার পিছনে নিয়ে যান",
|
||||
"delete": "মুছা",
|
||||
"copyStyles": "ডিজাইন কপি করুন",
|
||||
"pasteStyles": "ডিজাইন পেস্ট করুন",
|
||||
"stroke": "রেখাংশ",
|
||||
"background": "পটভূমি",
|
||||
"fill": "রং",
|
||||
"strokeWidth": "রেখাংশের বেধ",
|
||||
"strokeStyle": "রেখাংশের ডিজাইন",
|
||||
"strokeStyle_solid": "পুরু",
|
||||
"strokeStyle_dashed": "পাতলা",
|
||||
"strokeStyle_dotted": "বিন্দুবিন্দু",
|
||||
"sloppiness": "ভ্রান্তি",
|
||||
"opacity": "দৃশ্যমানতা",
|
||||
"textAlign": "লেখ অনুভূমি",
|
||||
"edges": "কোণ",
|
||||
"copyAsPng": "PNG হিসেবে ক্লিপবোর্ডে কপি করুন",
|
||||
"copyAsSvg": "SVG হিসেবে ক্লিপবোর্ডে কপি করুন",
|
||||
"copyText": "টেক্সট হিসেবে ক্লিপবোর্ডে কপি করুন",
|
||||
"bringForward": "সামনে আনুন",
|
||||
"sendToBack": "একদম পেছনে পাঠান",
|
||||
"bringToFront": "একদম সামনে আনুন",
|
||||
"sendBackward": "পেছনে পাঠান",
|
||||
"delete": "ডিলিট করুন",
|
||||
"copyStyles": "স্টাইলগুলো কপি করুন",
|
||||
"pasteStyles": "স্টাইলগুলো পেস্ট করুন",
|
||||
"stroke": "স্ট্রোক",
|
||||
"background": "ব্যাকগ্রাউন্ড",
|
||||
"fill": "ফিল",
|
||||
"strokeWidth": "স্ট্রোকের পুরুত্ব",
|
||||
"strokeStyle": "স্ট্রোকের স্টাইল",
|
||||
"strokeStyle_solid": "সলিড",
|
||||
"strokeStyle_dashed": "কাটা-কাটা",
|
||||
"strokeStyle_dotted": "ফোটা-ফোটা",
|
||||
"sloppiness": "স্ট্রোকের ধরণ",
|
||||
"opacity": "অস্বচ্ছতা",
|
||||
"textAlign": "লেখার দিক",
|
||||
"edges": "কোণা",
|
||||
"sharp": "তীক্ষ্ণ",
|
||||
"round": "গোল",
|
||||
"arrowheads": "তীরের শীর্ষভাগ",
|
||||
"round": "গোলাকার",
|
||||
"arrowheads": "তীরের মাথা",
|
||||
"arrowhead_none": "কিছু না",
|
||||
"arrowhead_arrow": "তীর",
|
||||
"arrowhead_bar": "রেখাংশ",
|
||||
"arrowhead_dot": "বিন্দু",
|
||||
"arrowhead_triangle": "ত্রিভূজ",
|
||||
"fontSize": "লেখনীর মাত্রা",
|
||||
"fontFamily": "লেখনীর হরফ",
|
||||
"onlySelected": "শুধুমাত্র সিলেক্টকৃত",
|
||||
"withBackground": "পটভূমি সমেত",
|
||||
"exportEmbedScene": "দৃশ্য",
|
||||
"exportEmbedScene_details": "সিনের ডেটা এক্সপোর্টকৃত পীএনজী বা এসভীজী ফাইলের মধ্যে সেভ করা হবে যাতে করে পরবর্তী সময়ে আপনি এডিট করতে পারেন। তবে এতে ফাইলের সাইজ বাড়বে",
|
||||
"addWatermark": "এক্সক্যালিড্র দ্বারা প্রস্তুত",
|
||||
"arrowhead_bar": "বার",
|
||||
"arrowhead_dot": "ডট",
|
||||
"arrowhead_triangle": "ত্রিভুজ",
|
||||
"fontSize": "ফন্ট সাইজ",
|
||||
"fontFamily": "ফন্ট ফ্যামিলি",
|
||||
"onlySelected": "শুধুমাত্র সিলেক্টেডগুলো",
|
||||
"withBackground": "ব্যাকগ্রাউন্ড",
|
||||
"exportEmbedScene": "সিন এম্বেড করুন",
|
||||
"exportEmbedScene_details": "সিনের ডেটা এক্সপোর্টকৃত PNG/SVG ফাইলের মধ্যে সেভ করা হবে যাতে করে পরবর্তী সময়ে আপনি এডিট করতে পারেন । তবে এতে ফাইলের সাইজ বাড়বে ।.",
|
||||
"addWatermark": "",
|
||||
"handDrawn": "হাতে আঁকা",
|
||||
"normal": "স্বাভাবিক",
|
||||
"code": "কোড",
|
||||
"small": "ছোট",
|
||||
"medium": "মাঝারি",
|
||||
"medium": "মধ্যবর্তী",
|
||||
"large": "বড়",
|
||||
"veryLarge": "অনেক বড়",
|
||||
"solid": "দৃঢ়",
|
||||
"hachure": "ভ্রুলেখা",
|
||||
"crossHatch": "ক্রস হ্যাচ",
|
||||
"thin": "পাতলা",
|
||||
"bold": "পুরু",
|
||||
"left": "বাম",
|
||||
"center": "কেন্দ্র",
|
||||
"right": "ডান",
|
||||
"extraBold": "অতি পুরু",
|
||||
"architect": "স্থপতি",
|
||||
"artist": "শিল্পী",
|
||||
"cartoonist": "চিত্রকার",
|
||||
"fileTitle": "ফাইলের নাম",
|
||||
"colorPicker": "রং পছন্দ করুন",
|
||||
"canvasColors": "ক্যানভাসের রং",
|
||||
"canvasBackground": "ক্যানভাসের পটভূমি",
|
||||
"drawingCanvas": "ব্যবহৃত ক্যানভাস",
|
||||
"layers": "মাত্রা",
|
||||
"actions": "ক্রিয়া",
|
||||
"language": "ভাষা",
|
||||
"liveCollaboration": "যুগ্ম কার্য",
|
||||
"duplicateSelection": "সদৃশ সিলেক্ট",
|
||||
"untitled": "অনামী",
|
||||
"name": "নাম",
|
||||
"yourName": "আপনার নাম",
|
||||
"madeWithExcalidraw": "এক্সক্যালিড্র দ্বারা তৈরি",
|
||||
"group": "দল গঠন করুন",
|
||||
"ungroup": "দল বিভেদ করুন",
|
||||
"collaborators": "সহযোগী",
|
||||
"showGrid": "গ্রিড দেখান",
|
||||
"addToLibrary": "সংগ্রহে যোগ করুন",
|
||||
"removeFromLibrary": "সংগ্রহ থেকে বের করুন",
|
||||
"libraryLoadingMessage": "সংগ্রহ তৈরি হচ্ছে",
|
||||
"libraries": "সংগ্রহ দেখুন",
|
||||
"loadingScene": "দৃশ্য তৈরি হচ্ছে",
|
||||
"align": "পংক্তিবিন্যাস",
|
||||
"alignTop": "উপর পংক্তি",
|
||||
"alignBottom": "নিম্ন পংক্তি",
|
||||
"alignLeft": "বাম পংক্তি",
|
||||
"alignRight": "ডান পংক্তি",
|
||||
"centerVertically": "উলম্ব কেন্দ্রিত",
|
||||
"centerHorizontally": "অনুভূমিক কেন্দ্রিত",
|
||||
"distributeHorizontally": "অনুভূমিকভাবে বিতরণ করুন",
|
||||
"distributeVertically": "উল্লম্বভাবে বিতরণ করুন",
|
||||
"flipHorizontal": "অনুভূমিক আবর্তন",
|
||||
"flipVertical": "উলম্ব আবর্তন",
|
||||
"viewMode": "দৃশ্য",
|
||||
"veryLarge": "",
|
||||
"solid": "",
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "",
|
||||
"bold": "",
|
||||
"left": "",
|
||||
"center": "",
|
||||
"right": "",
|
||||
"extraBold": "",
|
||||
"architect": "",
|
||||
"artist": "",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "",
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "",
|
||||
"drawingCanvas": "",
|
||||
"layers": "",
|
||||
"actions": "",
|
||||
"language": "",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "",
|
||||
"untitled": "",
|
||||
"name": "",
|
||||
"yourName": "",
|
||||
"madeWithExcalidraw": "",
|
||||
"group": "",
|
||||
"ungroup": "",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "ভাগ করুন",
|
||||
"share": "শেয়ার করুন",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
"decreaseFontSize": "লেখনীর মাত্রা কমান",
|
||||
"increaseFontSize": "লেখনীর মাত্রা বাড়ান",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "লিঙ্ক সংশোধন",
|
||||
"create": "লিঙ্ক তৈরী",
|
||||
"label": "লিঙ্ক নামকরণ"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "আবদ্ধ করুন",
|
||||
"unlock": "বিচ্ছিন্ন করুন",
|
||||
"lockAll": "সব আবদ্ধ করুন",
|
||||
"unlockAll": "সব বিচ্ছিন্ন করুন"
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": "প্রকাশিত",
|
||||
"sidebarLock": "লক"
|
||||
"statusPublished": "",
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "সংগ্রহে কিছু যোগ করা হয়নি",
|
||||
"hint_emptyLibrary": "এখানে যোগ করার জন্য ক্যানভাসে একটি বস্তু নির্বাচন করুন, অথবা নীচে, প্রকাশ্য সংগ্রহশালা থেকে একটি সংগ্রহ ইনস্টল করুন৷",
|
||||
"hint_emptyPrivateLibrary": "এখানে যোগ করার জন্য ক্যানভাসে একটি বস্তু নির্বাচন করুন"
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "ক্যানভাস সাফ করুন",
|
||||
"exportJSON": "জেসন নিবদ্ধ করুন",
|
||||
"exportImage": "চিত্র নিবদ্ধ করুন",
|
||||
"export": "নিবদ্ধ",
|
||||
"exportToPng": "পীএনজী ছবির মতন নিবদ্ধ করুন",
|
||||
"exportToSvg": "এসভীজী ছবির মতন নিবদ্ধ করুন",
|
||||
"copyToClipboard": "ক্লিপবোর্ডে কপি করুন",
|
||||
"copyPngToClipboard": "পীএনজী ছবির মতন ক্লিপবোর্ডে কপি করুন",
|
||||
"scale": "মাপ",
|
||||
"save": "জমা করুন",
|
||||
"saveAs": "অন্যভাবে জমা করুন",
|
||||
"load": "লোড করুন",
|
||||
"getShareableLink": "ভাগযোগ্য লিঙ্ক পান",
|
||||
"close": "বন্ধ করুন",
|
||||
"selectLanguage": "ভাষা চিহ্নিত করুন",
|
||||
"scrollBackToContent": "বিষয়বস্তুতে ফেরত যান",
|
||||
"zoomIn": "বড় করুন",
|
||||
"zoomOut": "ছোট করুন",
|
||||
"resetZoom": "স্বাভাবিক করুন",
|
||||
"menu": "তালিকা",
|
||||
"done": "সম্পন্ন",
|
||||
"edit": "সংশোধন করুন",
|
||||
"undo": "ফেরত যান",
|
||||
"redo": "পুনরায় করুন",
|
||||
"resetLibrary": "সংগ্রহ সাফ করুন",
|
||||
"createNewRoom": "নতুন রুম বানান",
|
||||
"fullScreen": "পূর্ণস্ক্রীন",
|
||||
"darkMode": "ডার্ক মোড",
|
||||
"lightMode": "লাইট মোড",
|
||||
"zenMode": "জেন মোড",
|
||||
"exitZenMode": "জেন মোড বন্ধ করুন",
|
||||
"cancel": "বাতিল",
|
||||
"clear": "সাফ",
|
||||
"remove": "বিয়োগ",
|
||||
"publishLibrary": "সংগ্রহ প্রকাশ করুন",
|
||||
"submit": "জমা করুন",
|
||||
"confirm": "নিশ্চিত করুন"
|
||||
"clearReset": "",
|
||||
"exportJSON": "",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyToClipboard": "",
|
||||
"copyPngToClipboard": "",
|
||||
"scale": "",
|
||||
"save": "",
|
||||
"saveAs": "",
|
||||
"load": "",
|
||||
"getShareableLink": "",
|
||||
"close": "",
|
||||
"selectLanguage": "",
|
||||
"scrollBackToContent": "",
|
||||
"zoomIn": "",
|
||||
"zoomOut": "",
|
||||
"resetZoom": "",
|
||||
"menu": "",
|
||||
"done": "",
|
||||
"edit": "",
|
||||
"undo": "",
|
||||
"redo": "",
|
||||
"resetLibrary": "",
|
||||
"createNewRoom": "",
|
||||
"fullScreen": "",
|
||||
"darkMode": "",
|
||||
"lightMode": "",
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "এটি পুরো ক্যানভাস সাফ করবে। আপনি কি নিশ্চিত?",
|
||||
"couldNotCreateShareableLink": "ভাগ করা যায় এমন লিঙ্ক তৈরি করা যায়নি।",
|
||||
"couldNotCreateShareableLinkTooBig": "ভাগ করা যায় এমন লিঙ্ক তৈরি করা যায়নি: দৃশ্যটি খুব বড়",
|
||||
"couldNotLoadInvalidFile": "অবৈধ ফাইল লোড করা যায়নি",
|
||||
"importBackendFailed": "ব্যাকেন্ড থেকে আপলোড ব্যর্থ হয়েছে।",
|
||||
"cannotExportEmptyCanvas": "খালি ক্যানভাস নিবদ্ধ করা যাবে না।",
|
||||
"couldNotCopyToClipboard": "ক্লিপবোর্ডে কপি করা যায়নি।",
|
||||
"decryptFailed": "তথ্য ডিক্রিপ্ট করা যায়নি।",
|
||||
"uploadedSecurly": "আপলোডটি এন্ড-টু-এন্ড এনক্রিপশনের মাধ্যমে সুরক্ষিত করা হয়েছে, যার অর্থ হল এক্সক্যালিড্র সার্ভার এবং তৃতীয় পক্ষের দ্বারা পড়তে পারা সম্ভব নয়।",
|
||||
"loadSceneOverridePrompt": "বাহ্যিক অঙ্কন লোড করা আপনার বিদ্যমান দৃশ্য প্রতিস্থাপন করবে। আপনি কি অবিরত করতে চান?",
|
||||
"collabStopOverridePrompt": "অধিবেশন বন্ধ করা আপনার পূর্ববর্তী, স্থানীয়ভাবে সঞ্চিত অঙ্কন ওভাররাইট করবে। আপনি কি নিশ্চিত?\n\n(যদি আপনি আপনার স্থানীয় অঙ্কন রাখতে চান, তাহলে শুধু ব্রাউজার ট্যাবটি বন্ধ করুন।)",
|
||||
"errorAddingToLibrary": "বস্তুটি সংগ্রহে যোগ করা যায়নি",
|
||||
"errorRemovingFromLibrary": "বস্তুটি সংগ্রহ থেকে বিয়োগ করা যায়নি",
|
||||
"confirmAddLibrary": "এটি আপনার সংগ্রহে {{numShapes}} আকার(গুলি) যোগ করবে। আপনি কি নিশ্চিত?",
|
||||
"imageDoesNotContainScene": "এই ছবিতে কোনো দৃশ্যের তথ্য আছে বলে মনে হয় না৷ আপনি কি নিবদ্ধ করার সময় দৃশ্য এমবেডিং করতে সক্ষম?",
|
||||
"cannotRestoreFromImage": "এই ফাইল থেকে দৃশ্য পুনরুদ্ধার করা যায়নি",
|
||||
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
|
||||
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
|
||||
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
|
||||
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।"
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "",
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "অসমর্থিত ফাইল।",
|
||||
"imageInsertError": "ছবি সন্নিবেশ করা যায়নি। পরে আবার চেষ্টা করুন...",
|
||||
"fileTooBig": "ফাইলটি খুব বড়। সর্বাধিক অনুমোদিত আকার হল {{maxSize}}৷",
|
||||
"svgImageInsertError": "এসভীজী ছবি সন্নিবেশ করা যায়নি। এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"invalidSVGString": "এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।",
|
||||
"importLibraryError": "সংগ্রহ লোড করা যায়নি"
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "বাছাই",
|
||||
"image": "চিত্র সন্নিবেশ",
|
||||
"rectangle": "আয়তক্ষেত্র",
|
||||
"diamond": "রুহিতন",
|
||||
"ellipse": "উপবৃত্ত",
|
||||
"arrow": "তীর",
|
||||
"line": "রেখা",
|
||||
"freedraw": "কলম",
|
||||
"text": "লেখা",
|
||||
"library": "সংগ্রহ",
|
||||
"lock": "আঁকার পরে নির্বাচিত টুল সক্রিয় রাখুন",
|
||||
"penMode": "পিঞ্চ-জুম প্রতিরোধ করুন এবং শুধুমাত্র কলম থেকে ইনপুট গ্রহণ করুন",
|
||||
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
|
||||
"eraser": "ঝাড়ন"
|
||||
"selection": "",
|
||||
"image": "",
|
||||
"rectangle": "",
|
||||
"diamond": "",
|
||||
"ellipse": "",
|
||||
"arrow": "",
|
||||
"line": "",
|
||||
"freedraw": "",
|
||||
"text": "",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "ক্যানভাস কার্যকলাপ",
|
||||
"selectedShapeActions": "বাছাই করা আকার(গুলি)র কার্যকলাপ",
|
||||
"shapes": "আকার(গুলি)"
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"shapes": ""
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "ক্যানভাস সরানোর জন্য মাউস হুইল বা স্পেসবার ধরে টানুন",
|
||||
"linearElement": "একাধিক বিন্দু শুরু করতে ক্লিক করুন, একক লাইনের জন্য টেনে আনুন",
|
||||
"freeDraw": "ক্লিক করুন এবং টেনে আনুন, আপনার কাজ শেষ হলে ছেড়ে দিন",
|
||||
"text": "বিশেষ্য: আপনি নির্বাচন টুলের সাথে যে কোনো জায়গায় ডাবল-ক্লিক করে পাঠ্য যোগ করতে পারেন",
|
||||
"text_selected": "লেখা সম্পাদনা করতে ডাবল-ক্লিক করুন বা এন্টার টিপুন",
|
||||
"text_editing": "লেখা সম্পাদনা শেষ করতে এসকেপ বা কন্ট্রোল/কম্যান্ড যোগে এন্টার টিপুন",
|
||||
"linearElementMulti": "শেষ বিন্দুতে ক্লিক করুন অথবা শেষ করতে এসকেপ বা এন্টার টিপুন",
|
||||
"lockAngle": "ঘোরানোর সময় আপনি শিফ্ট ধরে রেখে কোণ সীমাবদ্ধ করতে পারেন",
|
||||
"resize": "আপনি আকার পরিবর্তন করার সময় শিফ্ট ধরে রেখে অনুপাতকে সীমাবদ্ধ করতে পারেন,\nকেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরে রাখুন",
|
||||
"resizeImage": "আপনি শিফ্ট ধরে রেখে অবাধে আকার পরিবর্তন করতে পারেন, কেন্দ্র থেকে আকার পরিবর্তন করতে অল্ট ধরুন",
|
||||
"rotate": "আপনি ঘোরানোর সময় শিফ্ট ধরে রেখে কোণগুলিকে সীমাবদ্ধ করতে পারেন",
|
||||
"lineEditor_info": "পয়েন্ট সম্পাদনা করতে ডাবল-ক্লিক করুন বা এন্টার টিপুন",
|
||||
"lineEditor_pointSelected": "বিন্দু(গুলি) মুছতে ডিলিট টিপুন, কন্ট্রোল/কম্যান্ড যোগে ডি টিপুন নকল করতে অথবা সরানোর জন্য টানুন",
|
||||
"lineEditor_nothingSelected": "সম্পাদনা করার জন্য একটি বিন্দু নির্বাচন করুন (একাধিক নির্বাচন করতে শিফ্ট ধরে রাখুন),\nঅথবা অল্ট ধরে রাখুন এবং নতুন বিন্দু যোগ করতে ক্লিক করুন",
|
||||
"placeImage": "ছবিটি স্থাপন করতে ক্লিক করুন, অথবা নিজে আকার সেট করতে ক্লিক করুন এবং টেনে আনুন",
|
||||
"publishLibrary": "আপনার নিজস্ব সংগ্রহ প্রকাশ করুন",
|
||||
"bindTextToElement": "লেখা যোগ করতে এন্টার টিপুন",
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "মুছে ফেলার জন্য চিহ্নিত উপাদানগুলিকে ফিরিয়ে আনতে অল্ট ধরে রাখুন"
|
||||
"eraserRevert": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "প্রিভিউ দেখাতে অপারগ",
|
||||
"canvasTooBig": "ক্যানভাস অনেক বড়।",
|
||||
"canvasTooBigTip": "বিশেষ্য: দূরতম উপাদানগুলোকে একটু কাছাকাছি নিয়ে যাওয়ার চেষ্টা করুন।"
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain_pre": "একটি ত্রুটির সম্মুখীন হয়েছে৷ চেষ্টা করুন ",
|
||||
"headingMain_button": "পৃষ্ঠাটি পুনরায় লোড করার।",
|
||||
"clearCanvasMessage": "যদি পুনরায় লোড করা কাজ না করে, চেষ্টা করুন ",
|
||||
"clearCanvasMessage_button": "ক্যানভাস পরিষ্কার করার।",
|
||||
"clearCanvasCaveat": " এর ফলে কাজের ক্ষতি হবে ",
|
||||
"trackedToSentry_pre": "ত্রুটি ",
|
||||
"trackedToSentry_post": " আমাদের সিস্টেমে ট্র্যাক করা হয়েছিল।",
|
||||
"openIssueMessage_pre": "আমরা ত্রুটিতে আপনার দৃশ্যের তথ্য অন্তর্ভুক্ত না করার জন্য খুব সতর্ক ছিলাম। আপনার দৃশ্য ব্যক্তিগত না হলে, আমাদের অনুসরণ করার কথা বিবেচনা করুন ",
|
||||
"openIssueMessage_button": "ত্রুটি ইতিবৃত্ত।",
|
||||
"openIssueMessage_post": " অনুগ্রহ করে GitHub ইস্যুতে অনুলিপি এবং পেস্ট করে নীচের তথ্য অন্তর্ভুক্ত করুন।",
|
||||
"sceneContent": "দৃশ্য বিষয়বস্তু:"
|
||||
"headingMain_pre": "",
|
||||
"headingMain_button": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasMessage_button": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"trackedToSentry_pre": "",
|
||||
"trackedToSentry_post": "",
|
||||
"openIssueMessage_pre": "",
|
||||
"openIssueMessage_button": "",
|
||||
"openIssueMessage_post": "",
|
||||
"sceneContent": ""
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "আপনি আপনার সাথে সহযোগিতা করার জন্য আপনার বর্তমান দৃশ্যে লোকেদের আমন্ত্রণ জানাতে পারেন৷",
|
||||
"desc_privacy": "চিন্তা করবেন না, সেশনটি এন্ড-টু-এন্ড এনক্রিপশন ব্যবহার করে, তাই আপনি যা আঁকবেন তা গোপন থাকবে। এমনকি আমাদের সার্ভার আপনি যা নিয়ে এসেছেন তা দেখতে সক্ষম হবে না।",
|
||||
"button_startSession": "সেশন শুরু করুন",
|
||||
"button_stopSession": "সেশন বন্ধ করুন",
|
||||
"desc_inProgressIntro": "লাইভ-সহযোগীতার সেশন এখন চলছে।",
|
||||
"desc_shareLink": "আপনি যার সাথে সহযোগিতা করতে চান তাদের সাথে এই লিঙ্কটি ভাগ করুন: ",
|
||||
"desc_exitSession": "অধিবেশন বন্ধ করা আপনাকে রুম থেকে সংযোগ বিচ্ছিন্ন করবে, কিন্তু আপনি স্থানীয়ভাবে দৃশ্যের সাথে কাজ চালিয়ে যেতে সক্ষম হবেন। মনে রাখবেন যে এটি অন্য লোকেদের প্রভাবিত করবে না এবং তারা এখনও তাদের সংস্করণে সহযোগিতা করতে সক্ষম হবে।",
|
||||
"shareTitle": "এক্সক্যালিড্র লাইভ সহযোগিতা সেশনে যোগ দিন"
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "ত্রুটি"
|
||||
"title": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
@ -283,12 +279,12 @@
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "নিবদ্ধ",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "",
|
||||
"click": "ক্লিক",
|
||||
"click": "",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "",
|
||||
@ -300,7 +296,7 @@
|
||||
"editSelectedShape": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "অথবা",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
@ -369,7 +365,7 @@
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "কোণ",
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
@ -381,20 +377,20 @@
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": "প্রস্থ"
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "সংগ্রহশালায় যুক্ত হয়েছে",
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "ক্লিপবোর্ডে কপি করা হয়েছে।",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "",
|
||||
"selection": "বাছাই"
|
||||
"selection": ""
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "সাদা",
|
||||
"ffffff": "",
|
||||
"f8f9fa": "",
|
||||
"f1f3f5": "",
|
||||
"fff5f5": "",
|
||||
@ -424,7 +420,7 @@
|
||||
"82c91e": "",
|
||||
"fab005": "",
|
||||
"fd7e14": "",
|
||||
"000000": "কালো",
|
||||
"000000": "",
|
||||
"343a40": "",
|
||||
"495057": "",
|
||||
"c92a2a": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Crea un enllaç",
|
||||
"label": "Enllaç"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloca",
|
||||
"unlock": "Desbloca",
|
||||
|
@ -20,7 +20,7 @@
|
||||
"stroke": "Obrys",
|
||||
"background": "Pozadí",
|
||||
"fill": "Výplň",
|
||||
"strokeWidth": "Tloušťka tahu",
|
||||
"strokeWidth": "Šířka obrysu",
|
||||
"strokeStyle": "Styl tahu",
|
||||
"strokeStyle_solid": "Plný",
|
||||
"strokeStyle_dashed": "Čárkovaný",
|
||||
@ -55,46 +55,46 @@
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "Tenký",
|
||||
"bold": "Tlustý",
|
||||
"left": "Vlevo",
|
||||
"center": "Na střed",
|
||||
"right": "Vpravo",
|
||||
"extraBold": "Extra tlustý",
|
||||
"bold": "",
|
||||
"left": "",
|
||||
"center": "",
|
||||
"right": "",
|
||||
"extraBold": "",
|
||||
"architect": "",
|
||||
"artist": "",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "Název souboru",
|
||||
"colorPicker": "Výběr barvy",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "",
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "Pozadí plátna",
|
||||
"drawingCanvas": "",
|
||||
"layers": "Vrstvy",
|
||||
"actions": "Akce",
|
||||
"language": "Jazyk",
|
||||
"liveCollaboration": "Živá spolupráce",
|
||||
"duplicateSelection": "Duplikovat",
|
||||
"untitled": "Bez názvu",
|
||||
"name": "Název",
|
||||
"yourName": "Vaše jméno",
|
||||
"madeWithExcalidraw": "Vytvořeno v Excalidraw",
|
||||
"group": "Sloučit výběr do skupiny",
|
||||
"ungroup": "Zrušit sloučení skupiny",
|
||||
"collaborators": "Spolupracovníci",
|
||||
"showGrid": "Zobrazit mřížku",
|
||||
"addToLibrary": "Přidat do knihovny",
|
||||
"removeFromLibrary": "Odebrat z knihovny",
|
||||
"libraryLoadingMessage": "Načítání knihovny…",
|
||||
"libraries": "Procházet knihovny",
|
||||
"loadingScene": "Načítání scény…",
|
||||
"align": "Zarovnání",
|
||||
"actions": "",
|
||||
"language": "",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "",
|
||||
"untitled": "",
|
||||
"name": "",
|
||||
"yourName": "",
|
||||
"madeWithExcalidraw": "",
|
||||
"group": "",
|
||||
"ungroup": "",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "Zarovnat nahoru",
|
||||
"alignBottom": "Zarovnat dolů",
|
||||
"alignLeft": "Zarovnat vlevo",
|
||||
"alignRight": "Zarovnejte vpravo",
|
||||
"centerVertically": "Vycentrovat svisle",
|
||||
"centerHorizontally": "Vycentrovat vodorovně",
|
||||
"distributeHorizontally": "Rozložit horizontálně",
|
||||
"distributeVertically": "Rozložit svisle",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "Převrátit vodorovně",
|
||||
"flipVertical": "Převrátit svisle",
|
||||
"viewMode": "Náhled",
|
||||
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@ -164,18 +160,18 @@
|
||||
"lightMode": "Světlý režim",
|
||||
"zenMode": "Zen mód",
|
||||
"exitZenMode": "Opustit zen mód",
|
||||
"cancel": "Zrušit",
|
||||
"clear": "Vyčistit",
|
||||
"remove": "Odstranit",
|
||||
"publishLibrary": "Zveřejnit",
|
||||
"submit": "Odeslat",
|
||||
"confirm": "Potvrdit"
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "Toto vymaže celé plátno. Jste si jisti?",
|
||||
"couldNotCreateShareableLink": "Nepodařilo se vytvořit sdílitelný odkaz.",
|
||||
"couldNotCreateShareableLinkTooBig": "Nepodařilo se vytvořit sdílený odkaz: scéna je příliš velká",
|
||||
"couldNotLoadInvalidFile": "Nepodařilo se načíst neplatný soubor",
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
@ -216,7 +212,7 @@
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "Guma"
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
@ -278,72 +274,72 @@
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_button": "Uložit do souboru",
|
||||
"link_title": "Odkaz pro sdílení",
|
||||
"link_details": "Exportovat jako odkaz pouze pro čtení.",
|
||||
"link_button": "Exportovat do odkazu",
|
||||
"excalidrawplus_description": "Uložit scénu do vašeho pracovního prostoru Excalidraw+.",
|
||||
"excalidrawplus_button": "Exportovat",
|
||||
"excalidrawplus_exportError": "Export do Excalidraw+ se v tuto chvíli nezdařil..."
|
||||
"disk_button": "",
|
||||
"link_title": "",
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "Přečtěte si náš blog",
|
||||
"blog": "",
|
||||
"click": "kliknutí",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "Zakřivená šipka",
|
||||
"curvedLine": "Zakřivená čára",
|
||||
"documentation": "Dokumentace",
|
||||
"doubleClick": "dvojklik",
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"doubleClick": "",
|
||||
"drag": "tažení",
|
||||
"editor": "Editor",
|
||||
"editor": "",
|
||||
"editSelectedShape": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "nebo",
|
||||
"preventBinding": "Zabránit vázání šipky",
|
||||
"tools": "Nástroje",
|
||||
"shortcuts": "Klávesové zkratky",
|
||||
"textFinish": "Dokončit úpravy (textový editor)",
|
||||
"textNewLine": "Přidat nový řádek (textový editor)",
|
||||
"title": "Nápověda",
|
||||
"view": "Zobrazení",
|
||||
"zoomToFit": "Přiblížit na zobrazení všech prvků",
|
||||
"zoomToSelection": "Přiblížit na výběr",
|
||||
"toggleElementLock": "Zamknout/odemknout výběr"
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
"textFinish": "",
|
||||
"textNewLine": "",
|
||||
"title": "",
|
||||
"view": "",
|
||||
"zoomToFit": "",
|
||||
"zoomToSelection": "",
|
||||
"toggleElementLock": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Vymazat plátno"
|
||||
"title": ""
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Publikovat knihovnu",
|
||||
"itemName": "Název položky",
|
||||
"authorName": "Jméno autora",
|
||||
"githubUsername": "GitHub uživatelské jméno",
|
||||
"twitterUsername": "Twitter uživatelské jméno",
|
||||
"libraryName": "Název knihovny",
|
||||
"libraryDesc": "Popis knihovny",
|
||||
"website": "Webová stránka",
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"authorName": "",
|
||||
"githubUsername": "",
|
||||
"twitterUsername": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"website": "",
|
||||
"placeholder": {
|
||||
"authorName": "Jméno nebo uživatelské jméno",
|
||||
"libraryName": "Název vaší knihovny",
|
||||
"libraryDesc": "Popis Vaší knihovny, který pomůže lidem pochopit její využití",
|
||||
"githubHandle": "Github uživatelské jméno (nepovinné), abyste mohli upravovat knihovnu poté co je odeslána ke kontrole",
|
||||
"twitterHandle": "Twitter uživatelské jméno (nepovinné), abychom věděli koho označit při propagaci na Twitteru",
|
||||
"website": "Odkaz na Vaši osobní webovou stránku nebo jinam (nepovinné)"
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "Povinné",
|
||||
"website": "Zadejte platnou URL adresu"
|
||||
"required": "",
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "Odešlete svou knihovnu, pro zařazení do ",
|
||||
"link": "veřejného úložiště knihoven",
|
||||
"post": ", odkud ji budou moci při kreslení využít i ostatní uživatelé."
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím ",
|
||||
"link": "pokyny",
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteLicense": {
|
||||
@ -356,7 +352,7 @@
|
||||
"republishWarning": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Knihovna byla odeslána",
|
||||
"title": "",
|
||||
"content": "",
|
||||
"link": ""
|
||||
},
|
||||
@ -369,75 +365,75 @@
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Úhel",
|
||||
"element": "Prvek",
|
||||
"elements": "Prvky",
|
||||
"height": "Výška",
|
||||
"scene": "Scéna",
|
||||
"selected": "Vybráno",
|
||||
"storage": "Úložiště",
|
||||
"title": "Statistika pro nerdy",
|
||||
"total": "Celkem",
|
||||
"version": "Verze",
|
||||
"versionCopy": "Kliknutím zkopírujete",
|
||||
"versionNotAvailable": "Verze není k dispozici",
|
||||
"width": "Šířka"
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
"scene": "",
|
||||
"selected": "",
|
||||
"storage": "",
|
||||
"title": "",
|
||||
"total": "",
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Přidáno do knihovny",
|
||||
"copyStyles": "Styly byly zkopírovány.",
|
||||
"copyToClipboard": "Zkopírováno do schránky.",
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "Soubor byl uložen.",
|
||||
"fileSavedToFilename": "Uloženo do {filename}",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "plátno",
|
||||
"selection": "výběr"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "Bílá",
|
||||
"f8f9fa": "Šedá 0",
|
||||
"f1f3f5": "Šedá 1",
|
||||
"fff5f5": "Červená 0",
|
||||
"fff0f6": "Růžová 0",
|
||||
"f8f0fc": "Vínová 0",
|
||||
"f3f0ff": "Fialová 0",
|
||||
"edf2ff": "Indigová 0",
|
||||
"e7f5ff": "Modrá 0",
|
||||
"e3fafc": "Azurová 0",
|
||||
"e6fcf5": "Modrozelená 0",
|
||||
"ebfbee": "Zelená 0",
|
||||
"f4fce3": "Limetková 0",
|
||||
"fff9db": "Žlutá 0",
|
||||
"fff4e6": "Oranžová 0",
|
||||
"transparent": "Průhledná",
|
||||
"ced4da": "Šedá 4",
|
||||
"868e96": "Šedá 6",
|
||||
"fa5252": "Červená 6",
|
||||
"e64980": "Růžová 6",
|
||||
"be4bdb": "Vínová 6",
|
||||
"7950f2": "Fialová 6",
|
||||
"4c6ef5": "Indigová 6",
|
||||
"228be6": "Modrá 6",
|
||||
"15aabf": "Azurová 6",
|
||||
"12b886": "Modrozelená 6",
|
||||
"40c057": "Zelená 6",
|
||||
"82c91e": "Limetková 6",
|
||||
"fab005": "Žlutá 6",
|
||||
"fd7e14": "Oranžová 6",
|
||||
"000000": "Černá",
|
||||
"343a40": "Šedá 8",
|
||||
"495057": "Šedá 7",
|
||||
"c92a2a": "Červená 9",
|
||||
"a61e4d": "Růžová 9",
|
||||
"862e9c": "Vínová 9",
|
||||
"5f3dc4": "Fialová 9",
|
||||
"364fc7": "Indigová 9",
|
||||
"1864ab": "Modrá 9",
|
||||
"0b7285": "Azurová 9",
|
||||
"087f5b": "Modrozelená 9",
|
||||
"2b8a3e": "Zelená 9",
|
||||
"5c940d": "Limetková 9",
|
||||
"e67700": "Žlutá 9",
|
||||
"d9480f": "Oranzova"
|
||||
"ffffff": "",
|
||||
"f8f9fa": "",
|
||||
"f1f3f5": "",
|
||||
"fff5f5": "",
|
||||
"fff0f6": "",
|
||||
"f8f0fc": "",
|
||||
"f3f0ff": "",
|
||||
"edf2ff": "",
|
||||
"e7f5ff": "",
|
||||
"e3fafc": "",
|
||||
"e6fcf5": "",
|
||||
"ebfbee": "",
|
||||
"f4fce3": "",
|
||||
"fff9db": "",
|
||||
"fff4e6": "",
|
||||
"transparent": "",
|
||||
"ced4da": "",
|
||||
"868e96": "",
|
||||
"fa5252": "",
|
||||
"e64980": "",
|
||||
"be4bdb": "",
|
||||
"7950f2": "",
|
||||
"4c6ef5": "",
|
||||
"228be6": "",
|
||||
"15aabf": "",
|
||||
"12b886": "",
|
||||
"40c057": "",
|
||||
"82c91e": "",
|
||||
"fab005": "",
|
||||
"fd7e14": "",
|
||||
"000000": "",
|
||||
"343a40": "",
|
||||
"495057": "",
|
||||
"c92a2a": "",
|
||||
"a61e4d": "",
|
||||
"862e9c": "",
|
||||
"5f3dc4": "",
|
||||
"364fc7": "",
|
||||
"1864ab": "",
|
||||
"0b7285": "",
|
||||
"087f5b": "",
|
||||
"2b8a3e": "",
|
||||
"5c940d": "",
|
||||
"e67700": "",
|
||||
"d9480f": ""
|
||||
}
|
||||
}
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Link erstellen",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Linie bearbeiten",
|
||||
"exit": "Linieneditor verlassen"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Sperren",
|
||||
"unlock": "Entsperren",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Δημιουργία συνδέσμου",
|
||||
"label": "Σύνδεσμος"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Κλείδωμα",
|
||||
"unlock": "Ξεκλείδωμα",
|
||||
|
@ -114,11 +114,6 @@
|
||||
"create": "Create link",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Edit line",
|
||||
"exit": "Exit line editor"
|
||||
},
|
||||
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Crear enlace",
|
||||
"label": "Enlace"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Sortu esteka",
|
||||
"label": "Esteka"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Blokeatu",
|
||||
"unlock": "Desblokeatu",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "ایجاد پیوند",
|
||||
"label": "لینک"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "قفل",
|
||||
"unlock": "باز کردن",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Luo linkki",
|
||||
"label": "Linkki"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -10,23 +10,23 @@
|
||||
"copyAsPng": "Copier dans le presse-papier en PNG",
|
||||
"copyAsSvg": "Copier dans le presse-papier en SVG",
|
||||
"copyText": "Copier dans le presse-papier en tant que texte",
|
||||
"bringForward": "Envoyer vers l'avant",
|
||||
"bringForward": "Avancer d'un plan",
|
||||
"sendToBack": "Déplacer à l'arrière-plan",
|
||||
"bringToFront": "Mettre au premier plan",
|
||||
"bringToFront": "Placer au premier plan",
|
||||
"sendBackward": "Reculer d'un plan",
|
||||
"delete": "Supprimer",
|
||||
"copyStyles": "Copier les styles",
|
||||
"pasteStyles": "Coller les styles",
|
||||
"stroke": "Trait",
|
||||
"background": "Arrière-plan",
|
||||
"fill": "Remplissage",
|
||||
"strokeWidth": "Largeur du contour",
|
||||
"background": "Fond",
|
||||
"fill": "Motif du fond",
|
||||
"strokeWidth": "Épaisseur du trait",
|
||||
"strokeStyle": "Style du trait",
|
||||
"strokeStyle_solid": "Continu",
|
||||
"strokeStyle_dashed": "Tirets",
|
||||
"strokeStyle_dotted": "Pointillés",
|
||||
"sloppiness": "Style de tracé",
|
||||
"opacity": "Transparence",
|
||||
"opacity": "Opacité",
|
||||
"textAlign": "Alignement du texte",
|
||||
"edges": "Angles",
|
||||
"sharp": "Pointus",
|
||||
@ -106,7 +106,7 @@
|
||||
"personalLib": "Bibliothèque personnelle",
|
||||
"excalidrawLib": "Bibliothèque Excalidraw",
|
||||
"decreaseFontSize": "Diminuer la taille de police",
|
||||
"increaseFontSize": "Augmenter la taille de la police",
|
||||
"increaseFontSize": "Augmenter la taille de police",
|
||||
"unbindText": "Dissocier le texte",
|
||||
"bindText": "Associer le texte au conteneur",
|
||||
"link": {
|
||||
@ -114,10 +114,6 @@
|
||||
"create": "Ajouter un lien",
|
||||
"label": "Lien"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Modifier la ligne",
|
||||
"exit": "Quitter l'éditeur de ligne"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Verrouiller",
|
||||
"unlock": "Déverrouiller",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Crear ligazón",
|
||||
"label": "Ligazón"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "יצירת קישור",
|
||||
"label": "קישור"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "נעילה",
|
||||
"unlock": "ביטול נעילה",
|
||||
|
@ -71,7 +71,7 @@
|
||||
"layers": "परतें",
|
||||
"actions": "कार्रवाई",
|
||||
"language": "भाषा",
|
||||
"liveCollaboration": "जीवंत सहयोग",
|
||||
"liveCollaboration": "",
|
||||
"duplicateSelection": "डुप्लिकेट",
|
||||
"untitled": "अशीर्षित",
|
||||
"name": "नाम",
|
||||
@ -101,7 +101,7 @@
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "शेयर करें",
|
||||
"showStroke": "",
|
||||
"showBackground": "पृष्ठभूमि रंग वरक़ दिखाये",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "रेखा संपादित करे",
|
||||
"exit": "रेखा संपादक के बाहर"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "ताले में रखें",
|
||||
"unlock": "ताले से बाहर",
|
||||
@ -165,11 +161,11 @@
|
||||
"zenMode": "ज़ेन मोड",
|
||||
"exitZenMode": "जेन मोड से बाहर निकलें",
|
||||
"cancel": "",
|
||||
"clear": "साफ़ करे",
|
||||
"remove": "हटाएं",
|
||||
"publishLibrary": "प्रकाशित करें",
|
||||
"submit": "प्रस्तुत करे",
|
||||
"confirm": "पुष्टि करें"
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "इससे पूरा कैनवास साफ हो जाएगा। क्या आपको यकीन है?",
|
||||
@ -178,39 +174,39 @@
|
||||
"couldNotLoadInvalidFile": "अमान्य फ़ाइल लोड नहीं की जा सकी",
|
||||
"importBackendFailed": "बैकएंड से आयात करना विफल रहा।",
|
||||
"cannotExportEmptyCanvas": "खाली कैनवास निर्यात नहीं कर सकता।",
|
||||
"couldNotCopyToClipboard": "क्लिपबोर्ड पर कॉपी नहीं किया जा सका",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "डेटा को डिक्रिप्ट नहीं किया जा सका।",
|
||||
"uploadedSecurly": "अपलोड को एंड-टू-एंड एन्क्रिप्शन के साथ सुरक्षित किया गया है, जिसका मतलब है कि एक्सक्लूसिव सर्वर और थर्ड पार्टी कंटेंट नहीं पढ़ सकते हैं।",
|
||||
"loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?",
|
||||
"collabStopOverridePrompt": "चालू सत्र समाप्ति से आपका संग्रहित पूर्व स्थानीय अधिलेखन नष्ट होकर पुनः अधिलेखित होगा, क्या आपको यक़ीन हैं? ( यदी आपको पूर्व स्थापित अधिलेखन सुरक्षित चाहिये तो बस ब्राउज़र टैब बंद करे)",
|
||||
"errorAddingToLibrary": "संग्रह में जोडा न जा सका",
|
||||
"errorRemovingFromLibrary": "संग्रह से हटाया नहीं जा सका",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्टि करें आकार संख्या",
|
||||
"imageDoesNotContainScene": "ऐसा लगता है कि इस छवि में कोई दृश्य डेटा नहीं है। क्या आपने निर्यात के दौरान दृश्य एम्बेडिंग अनुमतित की है?",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "छवि फ़ाइल बहाल दृश्य नहीं है",
|
||||
"invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।",
|
||||
"resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?",
|
||||
"removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?",
|
||||
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं"
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "असमर्थित फाइल प्रकार",
|
||||
"imageInsertError": "छवि सम्मिलित नहीं की जा सकी. पुनः प्रयत्न करे...",
|
||||
"fileTooBig": "फ़ाइल ज़रूरत से ज़्यादा बड़ी हैं. अधिकतम अनुमित परिमाण {{maxSize}} हैं",
|
||||
"svgImageInsertError": "एसवीजी छवि सम्मिलित नहीं कर सके, एसवीजी रचना अनुचित हैं",
|
||||
"invalidSVGString": "अनुचित SVG",
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "कॉलेब सर्वर से कनेक्शन नहीं हो पा रहा. कृपया पृष्ठ को पुनः लाने का प्रयास करे.",
|
||||
"importLibraryError": "संग्रह प्रतिष्ठापित नहीं किया जा सका"
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "चयन",
|
||||
"image": "छवि सम्मिलित करें",
|
||||
"image": "",
|
||||
"rectangle": "आयात",
|
||||
"diamond": "तिर्यग्वर्ग",
|
||||
"ellipse": "दीर्घवृत्त",
|
||||
"arrow": "तीर",
|
||||
"line": "रेखा",
|
||||
"freedraw": "चित्रांतित करे",
|
||||
"freedraw": "",
|
||||
"text": "पाठ",
|
||||
"library": "लाइब्रेरी",
|
||||
"lock": "ड्राइंग के बाद चयनित टूल को सक्रिय रखें",
|
||||
@ -334,16 +330,16 @@
|
||||
},
|
||||
"errors": {
|
||||
"required": "",
|
||||
"website": "मान्य URL प्रविष्ट करें"
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "संग्रह सम्मिलित करने हेतु प्रस्तुत करें ",
|
||||
"link": "सार्वजनिक संग्रहालय",
|
||||
"post": "अन्य वक्तियों को उनके चित्रकारी में उपयोग के लिये"
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "संग्रह को पहले स्वीकृति आवश्यक कृपया यह पढ़ें ",
|
||||
"link": "दिशा-निर्देश",
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
},
|
||||
"noteLicense": {
|
||||
@ -353,7 +349,7 @@
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": "टिप्पणी: कुछ चुने हुवे आइटम पहले ही प्रकाशित/प्रस्तुत किए जा चुके हैं। किसी प्रकाशित संग्रह को अद्यतन करते समय या पहले से प्रस्तुत आइटम को पुन्हा प्रस्तुत करते समय, आप बस उसे केवल अद्यतन करें ।"
|
||||
"republishWarning": "टिप्पणी: कुछ चुने हुवे आइटम पहले ही प्रकाशित/प्रस्तुत किए जा चुके हैं। किसी प्रकाशित संग्रह को अद्यतन करते समय या प्रस्तुतित आइटम को पुन्हा प्रस्तुत करते समय, आप बस उसे केवल अद्यतन करें ।"
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Hivatkozás létrehozása",
|
||||
"label": "Hivatkozás"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Buat tautan",
|
||||
"label": "Tautan"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Edit tautan",
|
||||
"exit": "Keluar editor garis"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Kunci",
|
||||
"unlock": "Lepas",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Crea link",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Modifica linea",
|
||||
"exit": "Esci dall'editor di linea"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Blocca",
|
||||
"unlock": "Sblocca",
|
||||
@ -129,8 +125,8 @@
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Nessun elemento ancora aggiunto...",
|
||||
"hint_emptyLibrary": "Seleziona un elemento sulla tela per aggiungerlo qui, o installa una libreria dal repository pubblico qui sotto.",
|
||||
"hint_emptyPrivateLibrary": "Seleziona un elemento sulla tela per aggiungerlo qui."
|
||||
"hint_emptyLibrary": "Selezionare un elemento su tela per aggiungerlo qui, o installare una libreria dal repository pubblico, sotto.",
|
||||
"hint_emptyPrivateLibrary": "Selezionare un elemento su tela per aggiungerlo qui."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Svuota la tela",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "リンクを作成",
|
||||
"label": "リンク"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "ロック",
|
||||
"unlock": "ロック解除",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Snulfu-d aseɣwen",
|
||||
"label": "Aseɣwen"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Sekkeṛ",
|
||||
"unlock": "Serreḥ",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "링크 만들기",
|
||||
"label": "링크"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "선 수정하기",
|
||||
"exit": "선 편집기 종료"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "잠금",
|
||||
"unlock": "잠금 해제",
|
||||
@ -129,8 +125,8 @@
|
||||
},
|
||||
"library": {
|
||||
"noItems": "추가된 아이템 없음",
|
||||
"hint_emptyLibrary": "캔버스 위에서 아이템을 선택하여 여기에 추가를 하거나, 아래의 공용 저장소에서 라이브러리를 설치하세요.",
|
||||
"hint_emptyPrivateLibrary": "캔버스 위에서 아이템을 선택하여 여기 추가하세요."
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "캔버스 초기화",
|
||||
@ -327,9 +323,9 @@
|
||||
"placeholder": {
|
||||
"authorName": "이름 또는 사용자명",
|
||||
"libraryName": "당신의 라이브러리 이름",
|
||||
"libraryDesc": "사람들에게 라이브러리의 용도를 알기 쉽게 설명해주세요",
|
||||
"libraryDesc": "사람들이 쓰임새를 파악할 수 있도록 라이브러리에 대해 설명",
|
||||
"githubHandle": "GitHub 사용자명 (선택), 제출한 뒤에도 심사를 위해서 라이브러리를 수정할 때 사용됩니다",
|
||||
"twitterHandle": "Twitter 사용자명 (선택), Twitter를 통해서 홍보할 때 제작자를 밝히기 위해 사용됩니다",
|
||||
"twitterHandle": "Twitter 사용자명 (선택), Twitter를 통한 홍보에서 누가 제작했는지를 알리기 위해 사용됩니다",
|
||||
"website": "개인 웹사이트나 다른 어딘가의 링크 (선택)"
|
||||
},
|
||||
"errors": {
|
||||
|
@ -1,443 +0,0 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "دانانەوە",
|
||||
"pasteCharts": "دانانەوەی خشتەکان",
|
||||
"selectAll": "دیاریکردنی هەموو",
|
||||
"multiSelect": "زیادکردنی بۆ دیاریکراوەکان",
|
||||
"moveCanvas": "کانڤای زیاتر",
|
||||
"cut": "بڕین",
|
||||
"copy": "لەبەرگرتنەوە",
|
||||
"copyAsPng": "PNGلەبەرگرتنەوە بۆ تەختەنووس وەک",
|
||||
"copyAsSvg": "SVGلەبەرگرتنەوە بۆ تەختەنووس وەک",
|
||||
"copyText": "لەبەرگرتنەوە بۆ تەختەنووس وەک نوسین",
|
||||
"bringForward": "بیهێنە پێش",
|
||||
"sendToBack": "بنێرە دواوە",
|
||||
"bringToFront": "بهێنە بەردەم",
|
||||
"sendBackward": "بنێرە کۆتای",
|
||||
"delete": "سڕینەوە",
|
||||
"copyStyles": "لەبەرگرتنەوەی ستایل",
|
||||
"pasteStyles": "دانانەوەی ستایل",
|
||||
"stroke": "هێڵکار",
|
||||
"background": "باکگراوند",
|
||||
"fill": "پڕکردنەوە",
|
||||
"strokeWidth": "پانی هێڵکاری",
|
||||
"strokeStyle": "ستایلی هێڵکاری",
|
||||
"strokeStyle_solid": "سادە",
|
||||
"strokeStyle_dashed": "داشاوی",
|
||||
"strokeStyle_dotted": "خاڵدار",
|
||||
"sloppiness": "خواری",
|
||||
"opacity": "ناڕونی",
|
||||
"textAlign": "ڕێکخستنی دەق",
|
||||
"edges": "لێوارەکان",
|
||||
"sharp": "تیژ",
|
||||
"round": "چەماو",
|
||||
"arrowheads": "سەرەتیر",
|
||||
"arrowhead_none": "هیچیان",
|
||||
"arrowhead_arrow": "تیر",
|
||||
"arrowhead_bar": "هێڵ",
|
||||
"arrowhead_dot": "خاڵ",
|
||||
"arrowhead_triangle": "سێگۆشە",
|
||||
"fontSize": "قەبارەی فۆنت",
|
||||
"fontFamily": "خێزانی فۆنت",
|
||||
"onlySelected": "تەنها دیاریکراوەکان",
|
||||
"withBackground": "باکگراوند",
|
||||
"exportEmbedScene": "ئیمبێدکردنی دیمەنەکە",
|
||||
"exportEmbedScene_details": "هەنداردەکراو بۆ ئەوەی دیمەنەکە بتوانرێت بگەڕێنرێتەوە لێی (PNG/SVG) داتای دیمەنەکە هەڵدەگیرێت وەکو فایلی\nقەبارەی فایلە هەناردەکراوەکان زیاد دەکات.",
|
||||
"addWatermark": "زیادبکە \"Made with Excalidraw\"",
|
||||
"handDrawn": "دەست کێشراو",
|
||||
"normal": "ئاسایی",
|
||||
"code": "کۆد",
|
||||
"small": "بچووک",
|
||||
"medium": "ناوەند",
|
||||
"large": "گهوره",
|
||||
"veryLarge": "زۆر گهوره",
|
||||
"solid": "سادە",
|
||||
"hachure": "هاچور",
|
||||
"crossHatch": "کرۆس هاتچ",
|
||||
"thin": "تەنک",
|
||||
"bold": "تۆخ",
|
||||
"left": "چەپ",
|
||||
"center": "ناوهند",
|
||||
"right": "ڕاست",
|
||||
"extraBold": "زۆر تۆخ",
|
||||
"architect": "تەلارساز",
|
||||
"artist": "هونەرمەند",
|
||||
"cartoonist": "کارتۆنی",
|
||||
"fileTitle": "ناوی فایل",
|
||||
"colorPicker": "ڕەنگ هەڵگر",
|
||||
"canvasColors": "کانڤای بەکارهاتوو",
|
||||
"canvasBackground": "باکگراوندی کانڤاکان",
|
||||
"drawingCanvas": "کێشانی کانڤا",
|
||||
"layers": "چینەکان",
|
||||
"actions": "کردارەکان",
|
||||
"language": "زمان",
|
||||
"liveCollaboration": "هاوکاری ڕاستەوخۆ",
|
||||
"duplicateSelection": "لەبەرگرتنەوە",
|
||||
"untitled": "Untitled",
|
||||
"name": "ناو",
|
||||
"yourName": "ناوەکەت",
|
||||
"madeWithExcalidraw": "Made with Excalidraw",
|
||||
"group": "دیاریکردنی گروپ",
|
||||
"ungroup": "گروپی دیاریکراوەکان لابەرە",
|
||||
"collaborators": "هاوکارەکان",
|
||||
"showGrid": "گرید نیشانبدە",
|
||||
"addToLibrary": "زیادکردن بۆ کتێبخانە",
|
||||
"removeFromLibrary": "لابردن لە کتێبخانە",
|
||||
"libraryLoadingMessage": "...بارکردنی کتێبخانە",
|
||||
"libraries": "گەڕانی کتێبخانە",
|
||||
"loadingScene": "...بارکردنی دیمەنەکە",
|
||||
"align": "لاچەنکردن",
|
||||
"alignTop": "لاچەنکردن بۆ سەرەوە",
|
||||
"alignBottom": "لاچەنکردن بۆ خوارەوە",
|
||||
"alignLeft": "لاچەنکردن بۆ چەپ",
|
||||
"alignRight": "لاچەنکردن بۆ ڕاست",
|
||||
"centerVertically": "بە ستونی ناوەند بکە",
|
||||
"centerHorizontally": "بە ئاسۆی ناوەند بکە",
|
||||
"distributeHorizontally": "بە ئاسۆی دابەشی بکە",
|
||||
"distributeVertically": "بە ستونی دابەشی بکە",
|
||||
"flipHorizontal": "هەڵگەڕانەوەی ئاسۆیی",
|
||||
"flipVertical": "هەڵگەڕانەوەی ستونی",
|
||||
"viewMode": "دۆخی بینین",
|
||||
"toggleExportColorScheme": "گۆڕینی بارکردنی هێلکاری ڕەنگەکان",
|
||||
"share": "هاوبەشی پێکردن",
|
||||
"showStroke": "ڕەنگهەڵگری هێڵکار نیشانبدە",
|
||||
"showBackground": "ڕەنگهەڵگری باکگراوند نیشانبدە",
|
||||
"toggleTheme": "دۆخی ڕوکار بگۆڕە",
|
||||
"personalLib": "کتێبخانەی کەسی",
|
||||
"excalidrawLib": "کتێبخانەی Excalidraw",
|
||||
"decreaseFontSize": "کەمکردنەوەی قەبارەی فۆنت",
|
||||
"increaseFontSize": "زایدکردنی قەبارەی فۆنت",
|
||||
"unbindText": "دەقەکە جیابکەرەوە",
|
||||
"bindText": "دەقەکە ببەستەوە بە کۆنتەینەرەکەوە",
|
||||
"link": {
|
||||
"edit": "دەستکاریکردنی بەستەر",
|
||||
"create": "دروستکردنی بەستەر",
|
||||
"label": "بەستەر"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "دەستکاری کردنی دێڕ",
|
||||
"exit": "دەرچوون لە دەستکاریکەری دێڕ"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "قفڵکردن",
|
||||
"unlock": "کردنەوە",
|
||||
"lockAll": "قفڵکردنی هەموو",
|
||||
"unlockAll": "کردنەوەی قفلی هەمووی"
|
||||
},
|
||||
"statusPublished": "بڵاوکراوەتەوە",
|
||||
"sidebarLock": "هێشتنەوەی شریتی لا بە کراوەیی"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "هێشتا هیچ بڕگەیەک زیاد نەکراوە...",
|
||||
"hint_emptyLibrary": "شتێک لەسەر کانڤاس هەڵبژێرە بۆ ئەوەی لێرە زیاد بکەیت، یان کتێبخانەیەک لە کۆگای گشتیەوە دابمەزرێنە، لە خوارەوە.",
|
||||
"hint_emptyPrivateLibrary": "شتێک لەسەر کانڤاس هەڵبژێرە بۆ ئەوەی لێرە زیاد بکەیت."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "کانڤاسەکە ڕێست بکەرەوە",
|
||||
"exportJSON": "هەناردەکردن بۆ فایل",
|
||||
"exportImage": "پاشەکەوتکرد وەک وێنە",
|
||||
"export": "هەناردەکردن",
|
||||
"exportToPng": "هەناردەکردن بۆ PNG",
|
||||
"exportToSvg": "هەناردەکردن بۆ SVG",
|
||||
"copyToClipboard": "لهبهری بگرهوه بۆ تهختهنووس",
|
||||
"copyPngToClipboard": "لەبەرگرتنەوەی PNG بۆ تەختەنوس",
|
||||
"scale": "پێوەر",
|
||||
"save": "پاشەکەوت بکە بۆ فایلی بەردەست",
|
||||
"saveAs": "پاشەکەوتکردن وەک",
|
||||
"load": "بارکردن",
|
||||
"getShareableLink": "بەستەری هاوبەشیپێکردن بەدەستبهێنە",
|
||||
"close": "داخستن",
|
||||
"selectLanguage": "دیاریکردنی زمان",
|
||||
"scrollBackToContent": "گەڕاندنەوە بۆ ناوەڕۆک",
|
||||
"zoomIn": "نزیک خستنەوە",
|
||||
"zoomOut": "دوورخستنەوە",
|
||||
"resetZoom": "ڕێستکردنی زووم",
|
||||
"menu": "پێڕست",
|
||||
"done": "تەواو",
|
||||
"edit": "دەستکاری کردن",
|
||||
"undo": "گهڕانهوه بۆ پێشوو",
|
||||
"redo": "گهڕانهوه بۆ داهاتوو",
|
||||
"resetLibrary": "ڕێکخستنەوەی کتێبخانە",
|
||||
"createNewRoom": "ژوورێکی نوێ دروست بکە",
|
||||
"fullScreen": "پڕ بە شاشە",
|
||||
"darkMode": "دۆخی تاریک",
|
||||
"lightMode": "دۆخی ڕووناک",
|
||||
"zenMode": "دۆخی زێن",
|
||||
"exitZenMode": "بەجێهێشتنی دۆخی زێن",
|
||||
"cancel": "هەڵوەشاندنەوە",
|
||||
"clear": "خاوێنکردنەوە",
|
||||
"remove": "لابردن",
|
||||
"publishLibrary": "بڵاوکردنەوە",
|
||||
"submit": "پێشکەشکردن",
|
||||
"confirm": "دوپاتکردنەوە"
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "ئەمە هەموو کانڤاکە خاوێن دەکاتەوە، دڵنیایت؟",
|
||||
"couldNotCreateShareableLink": "نەتوانرا بەستەری هاوبەشیپێکردن دروستبکرێت",
|
||||
"couldNotCreateShareableLinkTooBig": "نەتوانرا بەستەری هاوبەشیپێکردن دروستبکرێت: دیمەنەکە زۆر گەورەیە",
|
||||
"couldNotLoadInvalidFile": "ناتوانرا باربکرێت، فایلەکە دروستنییە",
|
||||
"importBackendFailed": "هاوردەکردن لە پاشکۆکە سەرکەوتوو نەبوو.",
|
||||
"cannotExportEmptyCanvas": "ناتوانرێت کانڤای بەتاڵ هەناردەبکرێت",
|
||||
"couldNotCopyToClipboard": "ناتوانرا لەبەربگیرێتەوە بۆ تەختەنوس",
|
||||
"decryptFailed": "ناتوانرا داتاکان شیبکرێتەوە",
|
||||
"uploadedSecurly": "بارکردنەکە بە کۆدکردنی کۆتایی بۆ کۆتایی پارێزراوە، ئەمەش واتە سێرڤەری Excalidraw و لایەنی سێیەم ناتوانن ناوەڕۆکەکە بخوێننەوە.",
|
||||
"loadSceneOverridePrompt": "بارکردنی وێنەکێشانی دەرەکی جێگەی ناوەڕۆکی بەردەستت دەگرێتەوە. دەتەوێت بەردەوام بیت؟",
|
||||
"collabStopOverridePrompt": "وەستاندنی دانیشتنەکە وێنەکێشانی پێشووت دەنووسێتەوە کە لە ناوخۆدا هەڵگیراوە. ئایا دڵنیایت?\n\n(ئەگەر دەتەوێت وێنەکێشانی ناوخۆیی خۆت بهێڵیتەوە، لەبری ئەوە تەنها تابی وێبگەڕەکە دابخە).",
|
||||
"errorAddingToLibrary": "نەیتوانی بڕگە زیاد بکات بۆ کتێبخانە",
|
||||
"errorRemovingFromLibrary": "نەیتوانی بڕگە لە کتێبخانە بسڕێتەوە",
|
||||
"confirmAddLibrary": "ئەمە {{numShapes}} شێوە(ەکان) زیاد دەکات بۆ کتێبخانەکەت. ئایا دڵنیایت?",
|
||||
"imageDoesNotContainScene": "وادیارە ئەم وێنەیە هیچ داتایەکی دیمەنی تێدا نییە. ئایا دیمەنی چەسپاندنت لە کاتی هەناردەدا چالاک کردووە؟",
|
||||
"cannotRestoreFromImage": "ناتوانرێت دیمەنەکە بگەڕێندرێتەوە لەم فایلە وێنەیە",
|
||||
"invalidSceneUrl": "ناتوانێت دیمەنەکە هاوردە بکات لە URL ی دابینکراو. یان نادروستە، یان داتای \"ئێکسکالیدراو\" JSON ی دروستی تێدا نییە.",
|
||||
"resetLibrary": "ئەمە کتێبخانەکەت خاوێن دەکاتەوە. ئایا دڵنیایت?",
|
||||
"removeItemsFromsLibrary": "سڕینەوەی {{count}} ئایتم(ەکان) لە کتێبخانە؟",
|
||||
"invalidEncryptionKey": "کلیلی رەمزاندن دەبێت لە 22 پیت بێت. هاوکاری ڕاستەوخۆ لە کارخراوە."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "جۆری فایلی پشتگیری نەکراو.",
|
||||
"imageInsertError": "نەیتوانی وێنە داخڵ بکات. دواتر هەوڵ بدە",
|
||||
"fileTooBig": "فایلەکە زۆر گەورەیە. زۆرترین قەبارەی ڕێگەپێدراو {{maxSize}}}.",
|
||||
"svgImageInsertError": "نەیتوانی وێنەی SVG داخڵ بکات. نیشانەی ئێس ڤی جی نادروست دیارە.",
|
||||
"invalidSVGString": "ئێس ڤی جی نادروستە.",
|
||||
"cannotResolveCollabServer": "ناتوانێت پەیوەندی بکات بە سێرڤەری کۆلاب. تکایە لاپەڕەکە دووبارە باربکەوە و دووبارە هەوڵ بدەوە.",
|
||||
"importLibraryError": "نەیتوانی کتێبخانە بار بکات"
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "دەستنیشانکردن",
|
||||
"image": "داخڵکردنی وێنە",
|
||||
"rectangle": "لاکێشە",
|
||||
"diamond": "ئەڵماس",
|
||||
"ellipse": "هێلکەیی",
|
||||
"arrow": "تیر",
|
||||
"line": "هێڵ",
|
||||
"freedraw": "کێشان",
|
||||
"text": "دەق",
|
||||
"library": "کتێبخانە",
|
||||
"lock": "ئامێرە دیاریکراوەکان چالاک بهێڵەوە دوای وێنەکێشان",
|
||||
"penMode": "ڕێگری بکە لە گەورەکردنەوەی پینچ و قبولکردنی تێکردنی فریدراو تەنها لە پێنووسەوە",
|
||||
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
|
||||
"eraser": "سڕەر"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "کردارەکانی کانڤا",
|
||||
"selectedShapeActions": "کردارەکانی شێوەی دەستنیشانکراو",
|
||||
"shapes": "شێوەکان"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "بۆ جوڵاندنی کانڤاکە، لە کاتی ڕاکێشاندا ویل ماوس یان سپەیسبار دابگرە",
|
||||
"linearElement": "کرتە بکە بۆ دەستپێکردنی چەند خاڵێک، ڕایبکێشە بۆ یەک هێڵ",
|
||||
"freeDraw": "کرتە بکە و ڕایبکێشە، کاتێک تەواو بوویت دەست هەڵگرە",
|
||||
"text": "زانیاری: هەروەها دەتوانیت دەق زیادبکەیت بە دوو کرتەکردن لە هەر شوێنێک لەگەڵ ئامڕازی دەستنیشانکردن",
|
||||
"text_selected": "دووجار کلیک بکە یان ENTER بکە بۆ دەستکاریکردنی دەق",
|
||||
"text_editing": "بۆ تەواوکردنی دەستکاریکردنەکە Escape یان Ctrl/Cmd+ENTER بکە",
|
||||
"linearElementMulti": "کلیک لەسەر کۆتا خاڵ بکە یان Escape یان Enter بکە بۆ تەواوکردن",
|
||||
"lockAngle": "دەتوانیت گۆشە سنووردار بکەیت بە ڕاگرتنی SHIFT",
|
||||
"resize": "دەتوانیت ڕێژەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی گۆڕینی قەبارەدا،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
||||
"resizeImage": "دەتوانیت بە ئازادی قەبارە بگۆڕیت بە ڕاگرتنی SHIFT،\nALT ڕابگرە بۆ گۆڕینی قەبارە لە ناوەندەوە",
|
||||
"rotate": "دەتوانیت گۆشەکان سنووردار بکەیت بە ڕاگرتنی SHIFT لەکاتی سوڕانەوەدا",
|
||||
"lineEditor_info": "دووجار کلیک بکە یان Enter بکە بۆ دەستکاریکردنی خاڵەکان",
|
||||
"lineEditor_pointSelected": "بۆ لابردنی خاڵەکان Delete دابگرە، Ctrl Cmd+D بکە بۆ لەبەرگرتنەوە، یان بۆ جووڵە ڕاکێشان بکە",
|
||||
"lineEditor_nothingSelected": "خاڵێک هەڵبژێرە بۆ دەستکاریکردن (SHIFT ڕابگرە بۆ هەڵبژاردنی چەندین)،\nیان Alt ڕابگرە و کلیک بکە بۆ زیادکردنی خاڵە نوێیەکان",
|
||||
"placeImage": "کلیک بکە بۆ دانانی وێنەکە، یان کلیک بکە و ڕایبکێشە بۆ ئەوەی قەبارەکەی بە دەستی دابنێیت",
|
||||
"publishLibrary": "کتێبخانەی تایبەت بە خۆت بڵاوبکەرەوە",
|
||||
"bindTextToElement": "بۆ زیادکردنی دەق enter بکە",
|
||||
"deepBoxSelect": "CtrlOrCmd ڕابگرە بۆ هەڵبژاردنی قووڵ، و بۆ ڕێگریکردن لە ڕاکێشان",
|
||||
"eraserRevert": "بۆ گەڕاندنەوەی ئەو توخمانەی کە بۆ سڕینەوە نیشانە کراون، Alt ڕابگرە"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "ناتوانێت پێشبینین پیشان بدرێت",
|
||||
"canvasTooBig": "کانڤاکە لەوانەیە زۆر گەورەبێت.",
|
||||
"canvasTooBigTip": "زانیاری: هەوڵ بدە دوورترین توخمەکان کەمێک نزیکتر لە یەکتر بجوڵێنن."
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain_pre": "تووشی هەڵەیەک بوو. هەوڵ بدە ",
|
||||
"headingMain_button": "دووبارە بارکردنی لاپەڕەکە.",
|
||||
"clearCanvasMessage": "ئەگەر دووبارە بارکردنەوە کار نەکات، هەوڵبدە ",
|
||||
"clearCanvasMessage_button": "کانڤاکە خاوێن بکەیتەوە.",
|
||||
"clearCanvasCaveat": " ئەمە دەبێتە هۆی لەدەستدانی ئەوەی کە کردوتە ",
|
||||
"trackedToSentry_pre": "هەڵەکە لەگەڵ ناسێنەری ",
|
||||
"trackedToSentry_post": " لەسەر سیستەمەکەمان بەدواداچوونی بۆ کرا.",
|
||||
"openIssueMessage_pre": "ئێمە زۆر وریا بووین کە زانیارییەکانی دیمەنەکەت لەسەر هەڵەکە نەخەینەڕوو. ئەگەر دیمەنەکەت تایبەت نییە، تکایە بیر لە بەدواداچوون بکەنەوە بۆ ئێمە ",
|
||||
"openIssueMessage_button": "شوێنپێهەڵگری هەڵە.",
|
||||
"openIssueMessage_post": " تکایە ئەم زانیارییانەی خوارەوە کۆپی بکە و لە بەشی کێشەکانی Github دایبنێ.",
|
||||
"sceneContent": "پێکهاتەی ناو دیمەنەکە:"
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "دەتوانیت خەڵک بانگهێشت بکەیت بۆ دیمەنی ئێستات بۆ هاوکاری کردن لەگەڵت.",
|
||||
"desc_privacy": "نیگەران مەبە، دانیشتنەکە کۆدکردنی کۆتایی بە کۆتایی بەکاردەهێنێت، بۆیە هەرچییەک بکێشیت بە تایبەتی دەمێنێتەوە. تەنانەت سێرڤەرەکەمان ناتوانێت بزانێت کە تۆ چیت دروستکردووە.",
|
||||
"button_startSession": "دەستپێکردنی دانیشتن",
|
||||
"button_stopSession": "وەستاندنی دانیشتن",
|
||||
"desc_inProgressIntro": "دانیشتنی هاوکاری ڕاستەوخۆ ئێستا لە ئارادایە.",
|
||||
"desc_shareLink": "هاوبەشکردنی ئەم لینکە لەگەڵ هەر کەسێک کە دەتەوێت هاوکاری بکەیت لەگەڵ:",
|
||||
"desc_exitSession": "وەستاندنی دانیشتنەکە پەیوەندیت لەگەڵ ژوورەکە دەپچڕێنێت، بەڵام تۆ دەتوانیت بەردەوام بیت لە کارکردن لەگەڵ دیمەنەکە، لە ناوخۆدا. تێبینی بکە کە ئەمە کاریگەری لەسەر کەسانی تر نابێت، وە ئەوان هێشتا دەتوانن هاوکاری بکەن لەسەر وەشانەکەیان.",
|
||||
"shareTitle": "بەشداری بکە لە دانیشتنی هاریکاری ڕاستەوخۆ لە ئێکسکالیدراو"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "ههڵه ڕوویدا"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "پاشەکەوت بکە لە دیسک",
|
||||
"disk_details": "هەناردەکردنی داتای دیمەنەکە بۆ فایلێک کە دواتر دەتوانیت لێی هاوردە بکەیت.",
|
||||
"disk_button": "پاشەکەوت بکە بۆ فایل",
|
||||
"link_title": "بەستەری هاوبەشیپێکردن",
|
||||
"link_details": "ناردن وەک بەستەری تەنها-خوێندنەوە.",
|
||||
"link_button": "هەناردەکردن بۆ بەستەر",
|
||||
"excalidrawplus_description": "دیمەنەکە لە شوێنی کارکردنی Excalidraw+ هەڵبگرە.",
|
||||
"excalidrawplus_button": "هەناردەکردن",
|
||||
"excalidrawplus_exportError": "لەم ساتەدا نەتوانرا هەناردە بکرێت بۆ Excalidrow+..."
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "بلۆگەکەمان بخوێنەوە",
|
||||
"click": "گرتە",
|
||||
"deepSelect": "دەستنیشانکردنی قوڵ",
|
||||
"deepBoxSelect": "لەناو بۆکسەکەدا بە قووڵی هەڵبژێرە، و ڕێگری لە ڕاکێشان بکە",
|
||||
"curvedArrow": "تیری نوشتاوە",
|
||||
"curvedLine": "هێڵی نوشتاوە",
|
||||
"documentation": "دۆکیومێنتەیشن",
|
||||
"doubleClick": "دوو گرتە",
|
||||
"drag": "راکێشان",
|
||||
"editor": "دەستکاریکەر",
|
||||
"editSelectedShape": "دەستکاریکردنی شێوەی هەڵبژێردراو (دەق/تیر/هێڵ)",
|
||||
"github": "کێشەیەکت دۆزیەوە؟ پێشکەشکردن",
|
||||
"howto": "شوێن ڕینماییەکانمان بکەوە",
|
||||
"or": "یان",
|
||||
"preventBinding": "ڕێگریبکە لە نوشتاناوەی تیر",
|
||||
"tools": "ئامرازەکان",
|
||||
"shortcuts": "کورتکراوەکانی تەختەکلیل",
|
||||
"textFinish": "تەواوکردنی دەستکاریکردن (دەستکاریکەری دەق)",
|
||||
"textNewLine": "زیادکردنی دێڕی نوێ (دەستکاریکەری دەق)",
|
||||
"title": "یارماتی",
|
||||
"view": "دیمەن",
|
||||
"zoomToFit": "زووم بکە بۆ ئەوەی لەگەڵ هەموو توخمەکاندا بگونجێت",
|
||||
"zoomToSelection": "زووم بکە بۆ دەستنیشانکراوەکان",
|
||||
"toggleElementLock": "قفڵ/کردنەوەی دەستنیشانکراوەکان"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "خاوێنکردنەوەی کانڤا"
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "پێشکەشکردنی کتێبخانە",
|
||||
"itemName": "ناوی بڕگە",
|
||||
"authorName": "ناوی نوسەر",
|
||||
"githubUsername": "ناوی بەکارهێنەری Github",
|
||||
"twitterUsername": "ناوی بەکارهێنەری Twitter",
|
||||
"libraryName": "ناوی کتێبخانە",
|
||||
"libraryDesc": "وەسفی کتێبخانە",
|
||||
"website": "ماڵپەڕ",
|
||||
"placeholder": {
|
||||
"authorName": "ناوەکات یاخود ناوی بەکارهێنەر",
|
||||
"libraryName": "ناوی کتێبخانەکەت",
|
||||
"libraryDesc": "وەسفی کتێبخانەکەت بۆ یارمەتیدانی خەڵک بۆ تێگەیشتن لە بەکارهێنانی",
|
||||
"githubHandle": "ناوی GitHub (ئارەزوومەندانە)، بۆیە دەتوانیت دەستکاری کتێبخانەکە بکەیت کاتێک پێشکەش دەکرێت بۆ پێداچوونەوە",
|
||||
"twitterHandle": "ناوی بەکارهێنەری تویتەر (ئارەزوومەندانە)، بۆیە بزانین لەکاتی بانگەشەکردن لە ڕێگەی تویتەرەوە کریدت بۆ کێ بکەین",
|
||||
"website": "لینکی ماڵپەڕی تایبەتی خۆت یان شوێنێکی تر (ئارەزومەندانە)"
|
||||
},
|
||||
"errors": {
|
||||
"required": "داواکراوە",
|
||||
"website": "URLێکی دروست تێبنووسە"
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "کتێبخانەکەت بنێرە بۆ ئەوەی بخرێتە ناو ",
|
||||
"link": "کۆگای کتێبخانەی گشتی",
|
||||
"post": "بۆ ئەوەی کەسانی تر لە وێنەکێشانەکانیاندا بەکاری بهێنن."
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "کتێبخانەکە پێویستە سەرەتا بە دەست پەسەند بکرێت. تکایە بفەرمو بە خوێندنەوەی ",
|
||||
"link": "ڕێنماییەکان",
|
||||
"post": " پێش پێشکەشکردن. پێویستت بە ئەژمێری GitHub دەبێت بۆ پەیوەندیکردن و گۆڕانکاری ئەگەر داوای لێکرا، بەڵام بە توندی پێویست نییە."
|
||||
},
|
||||
"noteLicense": {
|
||||
"pre": "بە پێشکەشکردن، تۆ ڕەزامەندیت لەسەر بڵاوکردنەوەی کتێبخانەکە بەپێی ",
|
||||
"link": "مۆڵەتی MIT، ",
|
||||
"post": "کە بە کورتی مانای ئەوەیە کە هەرکەسێک دەتوانێت بە بێ سنوور بەکاری بهێنێت"
|
||||
},
|
||||
"noteItems": "هەر شتێکی کتێبخانە دەبێت ناوی تایبەتی خۆی هەبێت بۆ ئەوەی بتوانرێت فلتەر بکرێت. ئەم بابەتانەی کتێبخانانەی خوارەوە لەخۆدەگرێت:",
|
||||
"atleastOneLibItem": "تکایە بەلایەنی کەمەوە یەک بڕگەی کتێبخانە دیاریبکە بۆ دەستپێکردن",
|
||||
"republishWarning": "تێبینی: هەندێک لە ئایتمە دیاریکراوەکان نیشانکراون وەک ئەوەی پێشتر بڵاوکراونەتەوە/نێردراون. تەنها پێویستە شتەکان دووبارە پێشکەش بکەیتەوە لە کاتی نوێکردنەوەی کتێبخانەیەکی هەبوو یان پێشکەشکردن."
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "کتێبخانە پێشکەش کرا",
|
||||
"content": "سوپاس {{authorName}}. کتێبخانەکەت پێشکەش کراوە بۆ پێداچوونەوە. دەتوانیت بەدواداچوون بۆ دۆخەکە بکەیت",
|
||||
"link": "لێرە"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "ڕێکخستنەوەی کتێبخانە",
|
||||
"removeItemsFromLib": "لابردنی ئایتمە دیاریکراوەکان لە کتێبخانە"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "وێنەکێشانەکانت لە کۆتاییەوە بۆ کۆتایی کۆد کراون بۆیە سێرڤەرەکانی ئێکسکالیدرا هەرگیز نایانبینن.",
|
||||
"link": "بلۆگ پۆست لەسەر کۆدکردنی کۆتای بۆ کۆتای لە ئێکسکالیدرەو"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "گۆشە",
|
||||
"element": "توخم",
|
||||
"elements": "توخمەکان",
|
||||
"height": "بەرزی",
|
||||
"scene": "دیمەنەکە",
|
||||
"selected": "دەستنیشانکراوەکان",
|
||||
"storage": "بیرگە",
|
||||
"title": "ئامار بۆ نێردەکان",
|
||||
"total": "گشتی",
|
||||
"version": "وەشان",
|
||||
"versionCopy": "کلیک بۆ لەبەرگرتنەوە",
|
||||
"versionNotAvailable": "وەشان بەردەست نییە",
|
||||
"width": "پانی"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "زیادکرا بۆ کتێبخانە",
|
||||
"copyStyles": "ستایلی کۆپیکراو.",
|
||||
"copyToClipboard": "لەبەرگیرایەوە بۆ تەختەنوس.",
|
||||
"copyToClipboardAsPng": "کۆپی کراوە {{exportSelection}} بۆ کلیپبۆرد وەک PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "فایل هەڵگیرا.",
|
||||
"fileSavedToFilename": "هەڵگیراوە بۆ {filename}",
|
||||
"canvas": "کانڤاکان",
|
||||
"selection": "دەستنیشانکراوەکان"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "سپی",
|
||||
"f8f9fa": "خۆڵەمێشی 0",
|
||||
"f1f3f5": "خۆڵەمێشی 1",
|
||||
"fff5f5": "سور 0",
|
||||
"fff0f6": "پەمەی 0",
|
||||
"f8f0fc": "مێوژی 0",
|
||||
"f3f0ff": "مۆر 0",
|
||||
"edf2ff": "نیلی 0",
|
||||
"e7f5ff": "شین 0",
|
||||
"e3fafc": "شینی ئاسمانی 0",
|
||||
"e6fcf5": "سەوزباوی 0",
|
||||
"ebfbee": "سهوز 0",
|
||||
"f4fce3": "نارنجی 0",
|
||||
"fff9db": "زەرد 0",
|
||||
"fff4e6": "پرتەقاڵی 0",
|
||||
"transparent": "ڕوون",
|
||||
"ced4da": "خۆڵەمێشی 4",
|
||||
"868e96": "خۆڵەمێشی 6",
|
||||
"fa5252": "سور 6",
|
||||
"e64980": "پەمەی 6",
|
||||
"be4bdb": "مێوژی 6",
|
||||
"7950f2": "مۆر 6",
|
||||
"4c6ef5": "نیلی 6",
|
||||
"228be6": "شین 6",
|
||||
"15aabf": "شینی ئاسمانی 6",
|
||||
"12b886": "سەوزباوی 6",
|
||||
"40c057": "سهوز 6",
|
||||
"82c91e": "نارنجی 6",
|
||||
"fab005": "زەرد 6",
|
||||
"fd7e14": "پرتەقاڵی 6",
|
||||
"000000": "ڕەش",
|
||||
"343a40": "خۆڵەمێشی 8",
|
||||
"495057": "خۆڵەمێشی 7",
|
||||
"c92a2a": "سور 9",
|
||||
"a61e4d": "پەمەی 9",
|
||||
"862e9c": "مێوژی 9",
|
||||
"5f3dc4": "مۆر 9",
|
||||
"364fc7": "نیلی 9",
|
||||
"1864ab": "شین 9",
|
||||
"0b7285": "شینی ئاسمانی 9",
|
||||
"087f5b": "سەوزباوی 9",
|
||||
"2b8a3e": "سهوز 9",
|
||||
"5c940d": "نارنجی 9",
|
||||
"e67700": "زەرد 9",
|
||||
"d9480f": "پرتەقاڵی 9"
|
||||
}
|
||||
}
|
@ -114,10 +114,6 @@
|
||||
"create": "Sukurti nuorodą",
|
||||
"label": "Nuoroda"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Užrakinti",
|
||||
"unlock": "Atrakinti",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Izveidot saiti",
|
||||
"label": "Saite"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Rediģēt līniju",
|
||||
"exit": "Aizvērt līnijas redaktoru"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Fiksēt",
|
||||
"unlock": "Atbrīvot",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "दुवा तयार करा",
|
||||
"label": "दुवा"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "रेघ संपादन",
|
||||
"exit": "रेघ संपादकाबाहेर"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "कुलूपात ठेवा",
|
||||
"unlock": "कुलूपातून बाहेर",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Opprett lenke",
|
||||
"label": "Lenke"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Rediger linje",
|
||||
"exit": "Avslutt linjeredigering"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Lås",
|
||||
"unlock": "Lås opp",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Bewerk regel",
|
||||
"exit": "Verlaat regel-editor"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Lag lenke",
|
||||
"label": "Lenke"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -114,21 +114,17 @@
|
||||
"create": "Crear un ligam",
|
||||
"label": "Ligam"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Modificar la linha",
|
||||
"exit": "Sortir de l’editor de linha"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Verrolhar",
|
||||
"unlock": "Desverrolhar",
|
||||
"lockAll": "Tot verrolhar",
|
||||
"unlockAll": "Tot desverrolhar"
|
||||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": "Gardar la barra laterala dobèrta"
|
||||
"statusPublished": "",
|
||||
"sidebarLock": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Cap d’element pas encara apondut...",
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "ਕੜੀ ਬਣਾਓ",
|
||||
"label": "ਕੜੀ"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
|
@ -1,49 +1,48 @@
|
||||
{
|
||||
"ar-SA": 90,
|
||||
"ar-SA": 91,
|
||||
"bg-BG": 58,
|
||||
"bn-BD": 64,
|
||||
"bn-BD": 13,
|
||||
"ca-ES": 99,
|
||||
"cs-CZ": 66,
|
||||
"cs-CZ": 27,
|
||||
"da-DK": 34,
|
||||
"de-DE": 100,
|
||||
"el-GR": 99,
|
||||
"en": 100,
|
||||
"es-ES": 99,
|
||||
"eu-ES": 99,
|
||||
"es-ES": 100,
|
||||
"eu-ES": 100,
|
||||
"fa-IR": 98,
|
||||
"fi-FI": 97,
|
||||
"fi-FI": 98,
|
||||
"fr-FR": 100,
|
||||
"gl-ES": 45,
|
||||
"he-IL": 94,
|
||||
"hi-IN": 69,
|
||||
"hi-IN": 62,
|
||||
"hu-HU": 94,
|
||||
"id-ID": 100,
|
||||
"it-IT": 100,
|
||||
"ja-JP": 99,
|
||||
"ja-JP": 100,
|
||||
"kab-KAB": 95,
|
||||
"kk-KZ": 22,
|
||||
"ko-KR": 100,
|
||||
"ku-TR": 100,
|
||||
"lt-LT": 23,
|
||||
"ko-KR": 99,
|
||||
"lt-LT": 24,
|
||||
"lv-LV": 100,
|
||||
"mr-IN": 100,
|
||||
"my-MM": 44,
|
||||
"nb-NO": 100,
|
||||
"nl-NL": 86,
|
||||
"nn-NO": 95,
|
||||
"oc-FR": 99,
|
||||
"pa-IN": 87,
|
||||
"pl-PL": 89,
|
||||
"oc-FR": 98,
|
||||
"pa-IN": 88,
|
||||
"pl-PL": 88,
|
||||
"pt-BR": 100,
|
||||
"pt-PT": 99,
|
||||
"pt-PT": 100,
|
||||
"ro-RO": 100,
|
||||
"ru-RU": 99,
|
||||
"ru-RU": 100,
|
||||
"si-LK": 8,
|
||||
"sk-SK": 100,
|
||||
"sl-SI": 100,
|
||||
"sv-SE": 99,
|
||||
"ta-IN": 97,
|
||||
"tr-TR": 100,
|
||||
"sv-SE": 100,
|
||||
"ta-IN": 98,
|
||||
"tr-TR": 99,
|
||||
"uk-UA": 100,
|
||||
"vi-VN": 16,
|
||||
"zh-CN": 100,
|
||||
|
@ -9,7 +9,7 @@
|
||||
"copy": "Kopiuj",
|
||||
"copyAsPng": "Skopiuj do schowka jako plik PNG",
|
||||
"copyAsSvg": "Skopiuj do schowka jako plik SVG",
|
||||
"copyText": "Skopiuj do schowka jako tekst",
|
||||
"copyText": "",
|
||||
"bringForward": "Przenieś wyżej",
|
||||
"sendToBack": "Przenieś na spód",
|
||||
"bringToFront": "Przenieś na wierzch",
|
||||
@ -114,10 +114,6 @@
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
@ -216,7 +212,7 @@
|
||||
"lock": "Zablokuj wybrane narzędzie",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "Gumka"
|
||||
"eraser": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Narzędzia",
|
||||
@ -302,7 +298,7 @@
|
||||
"howto": "Skorzystaj z instrukcji",
|
||||
"or": "lub",
|
||||
"preventBinding": "Zapobiegaj wiązaniu strzałek",
|
||||
"tools": "Narzędzia",
|
||||
"tools": "",
|
||||
"shortcuts": "Skróty klawiszowe",
|
||||
"textFinish": "Zakończ edycję (edytor tekstu)",
|
||||
"textNewLine": "Dodaj nowy wiersz (edytor tekstu)",
|
||||
@ -317,7 +313,7 @@
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Opublikuj bibliotekę",
|
||||
"itemName": "Nazwa elementu",
|
||||
"itemName": "",
|
||||
"authorName": "Nazwa autora",
|
||||
"githubUsername": "Nazwa użytkownika na GitHubie",
|
||||
"twitterUsername": "Nazwa użytkownika Twitter",
|
||||
|
@ -5,7 +5,7 @@
|
||||
"selectAll": "Selecionar tudo",
|
||||
"multiSelect": "Adicionar elemento à seleção",
|
||||
"moveCanvas": "Mover tela",
|
||||
"cut": "Recortar",
|
||||
"cut": "Cortar",
|
||||
"copy": "Copiar",
|
||||
"copyAsPng": "Copiar para a área de transferência como PNG",
|
||||
"copyAsSvg": "Copiar para a área de transferência como SVG",
|
||||
@ -114,10 +114,6 @@
|
||||
"create": "Criar link",
|
||||
"label": "Link"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "Editar linha",
|
||||
"exit": "Sair do editor de linha"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
|
@ -114,10 +114,6 @@
|
||||
"create": "Criar ligação",
|
||||
"label": "Ligação"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloquear",
|
||||
"unlock": "Desbloquear",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user