Compare commits

...
Author SHA1 Message Date
dwelle b8da178ebd feat: update eye-dropper icon 2023-07-07 12:22:52 +02:00
zsviczianandGitHub 3ddcc48e4c fix: UI disappears when pressing the eyedropper shortcut on mobile (#6725) 2023-06-29 12:39:44 +02:00
29a5e982c3 feat: support scrollToContent opts.fitToViewport (#6581)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
2023-06-29 12:36:38 +02:00
b33fa6d6f6 fix: stronger enforcement of normalizeLink (#6728)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-29 12:14:42 +02:00
b7350f9707 fix: elements in non-existing frame getting removed (#6708)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-22 22:10:08 +00:00
David LuzarandGitHub 8dfa2a98bb fix: scrollbars renders but disable (#6706) 2023-06-22 14:35:01 +02:00
Wu KaiandGitHub fb01ce2a00 fix: typo in chart.ts (#6696)
fix: typo
2023-06-21 11:43:37 +05:30
Excalidraw BotandGitHub 3d57112480 chore: Update translations from Crowdin (#6677) 2023-06-19 17:10:09 +02:00
7558a4e2be feat: overwrite confirmation dialogs (#6658)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-06-19 17:08:12 +02:00
Aakansha DoshiandGitHub 6d56634289 fix: do not bind text to container using text tool when it has text already (#6694)
* fix: do not bind text to container using text tool when it has text already

* Update src/element/textWysiwyg.test.tsx
2023-06-19 17:28:45 +05:30
Milos VetesnikandGitHub 0aa1e66486 feat: simple analitycs (#6683)
* Simple analytics for iframe and webpage

* added logic for tracking specific categories of events to reduce it

* enviroment vars clean up

* fix: lint for index.html
2023-06-19 11:18:28 +02:00
Aakansha DoshiandGitHub 7f7128ec09 fix: don't allow binding text to images (#6693) 2023-06-19 13:47:28 +05:30
107 changed files with 1895 additions and 478 deletions
+1 -5
View File
@@ -20,14 +20,10 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
REACT_APP_DISABLE_TRACKING=true
FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags
# To enable bounding box for text containers
+1 -10
View File
@@ -11,14 +11,5 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com
REACT_APP_DISABLE_TRACKING=
@@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
```tsx
(
target?: ExcalidrawElement | ExcalidrawElement[],
opts?:
| {
fitToContent?: boolean;
animate?: boolean;
duration?: number;
}
| {
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) => void
```
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
+1
View File
@@ -19,6 +19,7 @@
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2",
+33 -38
View File
@@ -148,33 +148,6 @@
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
@@ -227,17 +200,39 @@
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
<script>
// need to load this script dynamically bcs. of iframe embed tracking
var scriptEle = document.createElement("script");
scriptEle.setAttribute(
"src",
"https://scripts.simpleanalyticscdn.com/latest.js",
);
scriptEle.setAttribute("type", "text/javascript");
scriptEle.setAttribute("defer", true);
scriptEle.setAttribute("async", true);
// if iframe
if (window.self !== window.top) {
scriptEle.setAttribute("data-auto-collect", true);
}
document.body.appendChild(scriptEle);
// if iframe
if (window.self !== window.top) {
scriptEle.addEventListener("load", () => {
if (window.sa_pageview) {
window.window.sa_event(action, {
category: "iframe",
label: "embed",
value: window.location.pathname,
});
}
});
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+101 -35
View File
@@ -20,7 +20,6 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({
@@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
: getCommonBounds(
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
let newZoomValue;
let scrollX;
let scrollY;
if (fitToViewport) {
const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1;
newZoomValue =
Math.min(
appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight,
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
scrollX,
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: false,
});
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
@@ -279,11 +322,34 @@ export const actionZoomToSelected = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
fitToViewport: true,
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+2 -1
View File
@@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
+5 -14
View File
@@ -5,6 +5,9 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
@@ -12,12 +15,8 @@ export const trackEvent = (
return;
}
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}
if (window.sa_event) {
@@ -27,14 +26,6 @@ export const trackEvent = (
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
+3 -3
View File
@@ -180,7 +180,7 @@ const commonProps = {
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@@ -250,7 +250,7 @@ const chartLines = (
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
@@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
+83 -29
View File
@@ -245,6 +245,7 @@ import {
isTransparent,
easeToValuesRAF,
muteFSAbortError,
easeOut,
} from "../utils";
import {
ContextMenu,
@@ -291,13 +292,12 @@ import {
} from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import {
normalizeLink,
showHyperlinkTooltip,
hideHyperlinkToolip,
Hyperlink,
isPointHittingLinkIcon,
isLocalLink,
} from "../element/Hyperlink";
import { isLocalLink, normalizeLink } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
@@ -321,10 +321,7 @@ import {
actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame,
} from "../actions/actionFrame";
import {
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
@@ -1720,7 +1717,7 @@ class App extends React.Component<AppProps, AppState> {
theme: this.state.theme,
imageCache: this.imageCache,
isExporting: false,
renderScrollbars: !this.device.isMobile,
renderScrollbars: false,
},
callback: ({ atLeastOneVisibleElement, scrollBars }) => {
if (scrollBars) {
@@ -2240,27 +2237,51 @@ class App extends React.Component<AppProps, AppState> {
target:
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
opts?:
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: never;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%)
*/
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
},
) => {
this.cancelInProgresAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
const targets = Array.isArray(target) ? target : [target];
const targetElements = Array.isArray(target) ? target : [target];
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent) {
// compute an appropriate viewport location (scroll X, Y) and zoom level
// that fit the target elements on the scene
const { appState } = zoomToFitElements(targets, this.state, false);
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
const scroll = calculateScrollCenter(
targetElements,
this.state,
this.canvas,
);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
@@ -2270,19 +2291,42 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
// zoom animation could become problematic on scenes with large number
// of elements, setting it to its final value to improve user experience.
//
// using zoomCanvas() to zoom on current viewport center
this.zoomCanvas(zoom.value);
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
const cancel = easeToValuesRAF(
[origScrollX, origScrollY],
[scrollX, scrollY],
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
{ duration: opts?.duration ?? 500 },
);
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
@@ -3054,7 +3098,10 @@ class App extends React.Component<AppProps, AppState> {
container,
);
if (container && parentCenterPosition) {
shouldBindToContainer = true;
const boundTextElementToContainer = getBoundTextElement(container);
if (!boundTextElementToContainer) {
shouldBindToContainer = true;
}
}
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
@@ -3349,12 +3396,19 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
const url = this.hitLinkElement.link;
let url = this.hitLinkElement.link;
if (url) {
url = normalizeLink(url);
let customEvent;
if (this.props.onLinkOpen) {
customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
this.props.onLinkOpen(this.hitLinkElement, customEvent);
this.props.onLinkOpen(
{
...this.hitLinkElement,
link: url,
},
customEvent,
);
}
if (!customEvent?.defaultPrevented) {
const target = isLocalLink(url) ? "_self" : "_blank";
@@ -3362,7 +3416,7 @@ class App extends React.Component<AppProps, AppState> {
// https://mathiasbynens.github.io/rel-noopener/
if (newWindow) {
newWindow.opener = null;
newWindow.location = normalizeLink(url);
newWindow.location = url;
}
}
}
+20 -4
View File
@@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps {
children: React.ReactNode;
className?: string;
size?: "small" | "regular" | "wide";
size?: DialogSize;
onCloseRequest(): void;
title: React.ReactNode | false;
autofocus?: boolean;
closeOnClickOutside?: boolean;
}
function getDialogSize(size: DialogSize): number {
if (size && typeof size === "number") {
return size;
}
switch (size) {
case "small":
return 550;
case "wide":
return 1024;
case "regular":
default:
return 800;
}
}
export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
@@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
maxWidth={getDialogSize(props.size)}
onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside}
>
+138 -42
View File
@@ -2,20 +2,140 @@
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--text-color: transparent;
--border-color: transparent;
--back-color: transparent;
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
color: var(--text-color);
background-color: var(--back-color);
border-color: var(--border-color);
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
}
&:active {
--back-color: var(--color-primary-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
}
}
}
&--color-danger {
color: var(--input-bg-color);
&.ExcButton--variant-filled {
--text-color: var(--color-danger-text);
--back-color: var(--color-danger-dark);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
&:hover {
--back-color: var(--color-danger-darker);
}
&:active {
--back-color: var(--color-danger-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-danger);
--border-color: var(--color-danger);
--back-color: transparent;
&:hover {
--text-color: var(--color-danger-darkest);
--border-color: var(--color-danger-darkest);
}
&:active {
--text-color: var(--color-danger-darker);
--border-color: var(--color-danger-darker);
}
}
}
&--color-muted {
&.ExcButton--variant-filled {
--text-color: var(--island-bg-color);
--back-color: var(--color-gray-50);
&:hover {
--back-color: var(--color-gray-60);
}
&:active {
--back-color: var(--color-gray-80);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-muted-background);
--border-color: var(--color-muted);
--back-color: var(--island-bg-color);
&:hover {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darker);
}
&:active {
--text-color: var(--color-muted-background-darker);
--border-color: var(--color-muted-darkest);
}
}
}
&--color-warning {
&.ExcButton--variant-filled {
--text-color: black;
--back-color: var(--color-warning-dark);
&:hover {
--back-color: var(--color-warning-darker);
}
&:active {
--back-color: var(--color-warning-darkest);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-warning-dark);
--border-color: var(--color-warning-dark);
--back-color: var(--input-bg-color);
&:hover {
--text-color: var(--color-warning-darker);
--border-color: var(--color-warning-darker);
}
&:active {
--text-color: var(--color-warning-darkest);
--border-color: var(--color-warning-darkest);
}
}
}
display: flex;
@@ -25,6 +145,8 @@
flex-wrap: nowrap;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant";
@@ -33,9 +155,9 @@
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-weight: 600;
font-size: 0.875rem;
height: 3rem;
min-height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
@@ -45,48 +167,22 @@
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&--fullWidth {
width: 100%;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
+4 -1
View File
@@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
@@ -17,6 +17,7 @@ export type FilledButtonProps = {
color?: ButtonColor;
size?: ButtonSize;
className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
};
@@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled",
color = "primary",
size = "medium",
fullWidth,
className,
},
ref,
@@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className,
)}
onClick={onClick}
+13 -1
View File
@@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@@ -99,6 +100,15 @@ const DefaultMainMenu: React.FC<{
);
};
const DefaultOverwriteConfirmDialog = () => {
return (
<OverwriteConfirmDialog __fallback>
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Actions.ExportToImage />
</OverwriteConfirmDialog>
);
};
const LayerUI = ({
actionManager,
appState,
@@ -343,6 +353,7 @@ const LayerUI = ({
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -374,6 +385,7 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
@@ -387,7 +399,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && !eyeDropperState && (
{device.isMobile && (
<MobileMenu
appState={appState}
elements={elements}
+1 -1
View File
@@ -3,7 +3,7 @@
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 10;
z-index: var(--zIndex-modal);
}
.Modal {
@@ -0,0 +1,126 @@
@import "../../css/variables.module";
.excalidraw {
.OverwriteConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
isolation: isolate;
h3 {
margin: 0;
font-weight: 700;
font-size: 1.3125rem;
line-height: 130%;
align-self: flex-start;
color: var(--text-primary-color);
}
&__Description {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
gap: 1rem;
@include isMobile {
flex-direction: column;
text-align: center;
}
padding: 2.5rem;
background: var(--color-danger-background);
border-radius: 0.5rem;
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
color: var(--color-danger-color);
&__spacer {
flex-grow: 1;
}
&__icon {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2.5rem;
background: var(--color-danger-icon-background);
width: 3.5rem;
height: 3.5rem;
padding: 0.75rem;
svg {
color: var(--color-danger-icon-color);
width: 1.5rem;
height: 1.5rem;
}
}
&.OverwriteConfirm__Description--color-warning {
background: var(--color-warning-background);
color: var(--color-warning-color);
.OverwriteConfirm__Description__icon {
background: var(--color-warning-icon-background);
flex: 0 0 auto;
svg {
color: var(--color-warning-icon-color);
}
}
}
}
&__Actions {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
justify-content: center;
gap: 1.5rem;
@include isMobile {
flex-direction: column;
}
&__Action {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
gap: 0.75rem;
flex-basis: 50%;
flex-grow: 0;
&__content {
height: 100%;
font-size: 0.875rem;
text-align: center;
}
h4 {
font-weight: 700;
font-size: 1.125rem;
line-height: 130%;
margin: 0;
color: var(--text-primary-color);
}
}
}
}
}
@@ -0,0 +1,76 @@
import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
import { FilledButton } from "../FilledButton";
import { alertTriangleIcon } from "../icons";
import { Actions, Action } from "./OverwriteConfirmActions";
import "./OverwriteConfirm.scss";
export type OverwriteConfirmDialogProps = {
children: React.ReactNode;
};
const OverwriteConfirmDialog = Object.assign(
withInternalFallback(
"OverwriteConfirmDialog",
({ children }: OverwriteConfirmDialogProps) => {
const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom,
jotaiScope,
);
if (!overwriteConfirmState.active) {
return null;
}
const handleClose = () => {
overwriteConfirmState.onClose();
setState((state) => ({ ...state, active: false }));
};
const handleConfirm = () => {
overwriteConfirmState.onConfirm();
setState((state) => ({ ...state, active: false }));
};
return (
<OverwriteConfirmDialogTunnel.In>
<Dialog onCloseRequest={handleClose} title={false} size={916}>
<div className="OverwriteConfirm">
<h3>{overwriteConfirmState.title}</h3>
<div
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
>
<div className="OverwriteConfirm__Description__icon">
{alertTriangleIcon}
</div>
<div>{overwriteConfirmState.description}</div>
<div className="OverwriteConfirm__Description__spacer"></div>
<FilledButton
color={overwriteConfirmState.color}
size="large"
label={overwriteConfirmState.actionLabel}
onClick={handleConfirm}
/>
</div>
<Actions>{children}</Actions>
</div>
</Dialog>
</OverwriteConfirmDialogTunnel.In>
);
},
),
{
Actions,
Action,
},
);
export { OverwriteConfirmDialog };
@@ -0,0 +1,85 @@
import React from "react";
import { FilledButton } from "../FilledButton";
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
import { actionSaveFileToDisk } from "../../actions";
import { useI18n } from "../../i18n";
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
export type ActionProps = {
title: string;
children: React.ReactNode;
actionLabel: string;
onClick: () => void;
};
export const Action = ({
title,
children,
actionLabel,
onClick,
}: ActionProps) => {
return (
<div className="OverwriteConfirm__Actions__Action">
<h4>{title}</h4>
<div className="OverwriteConfirm__Actions__Action__content">
{children}
</div>
<FilledButton
variant="outlined"
color="muted"
label={actionLabel}
size="large"
fullWidth
onClick={onClick}
/>
</div>
);
};
export const ExportToImage = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const setAppState = useExcalidrawSetAppState();
return (
<Action
title={t("overwriteConfirm.action.exportToImage.title")}
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
</Action>
);
};
export const SaveToDisk = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<Action
title={t("overwriteConfirm.action.saveToDisk.title")}
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
>
{t("overwriteConfirm.action.saveToDisk.description")}
</Action>
);
};
const Actions = Object.assign(
({ children }: { children: React.ReactNode }) => {
return <div className="OverwriteConfirm__Actions">{children}</div>;
},
{
ExportToImage,
SaveToDisk,
},
);
export { Actions };
@@ -0,0 +1,46 @@
import { atom } from "jotai";
import { jotaiStore } from "../../jotai";
import React from "react";
export type OverwriteConfirmState =
| {
active: true;
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
onClose: () => void;
onConfirm: () => void;
onReject: () => void;
}
| { active: false };
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
active: false,
});
export async function openConfirmModal({
title,
description,
actionLabel,
color,
}: {
title: string;
description: React.ReactNode;
actionLabel: string;
color: "danger" | "warning";
}) {
return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, {
active: true,
onConfirm: () => resolve(true),
onClose: () => resolve(false),
onReject: () => resolve(false),
title,
description,
actionLabel,
color,
});
});
}
+91
View File
@@ -0,0 +1,91 @@
@import "../css/variables.module";
.excalidraw {
.ShareableLinkDialog {
display: flex;
flex-direction: column;
gap: 1.5rem;
color: var(--text-primary-color);
::selection {
background: var(--color-primary-light-darker);
}
h3 {
font-family: "Assistant";
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
margin: 0;
}
&__popover {
@keyframes RoomDialog__popover__scaleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
box-sizing: border-box;
z-index: 100;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
padding: 0.125rem 0.5rem;
gap: 0.125rem;
height: 1.125rem;
border: none;
border-radius: 0.6875rem;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 110%;
background: var(--color-success-lighter);
color: var(--color-success);
& > svg {
width: 0.875rem;
height: 0.875rem;
}
transform-origin: var(--radix-popover-content-transform-origin);
animation: RoomDialog__popover__scaleIn 150ms ease-out;
}
&__linkRow {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.75rem;
}
&__description {
border-top: 1px solid var(--color-gray-20);
padding: 0.5rem 0.5rem 0;
font-weight: 400;
font-size: 0.75rem;
line-height: 150%;
& p {
margin: 0;
}
& p + p {
margin-top: 1em;
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../clipboard";
import { useI18n } from "../i18n";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { FilledButton } from "./FilledButton";
import { copyIcon, tablerCheckIcon } from "./icons";
import "./ShareableLinkDialog.scss";
export type ShareableLinkDialogProps = {
link: string;
onCloseRequest: () => void;
setErrorMessage: (error: string) => void;
};
export const ShareableLinkDialog = ({
link,
onCloseRequest,
setErrorMessage,
}: ShareableLinkDialogProps) => {
const { t } = useI18n();
const [justCopied, setJustCopied] = useState(false);
const timerRef = useRef<number>(0);
const ref = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};
return (
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
<div className="ShareableLinkDialog">
<h3>Shareable link</h3>
<div className="ShareableLinkDialog__linkRow">
<TextField
ref={ref}
label="Link"
readonly
fullWidth
value={link}
selectOnRender
/>
<Popover.Root open={justCopied}>
<Popover.Trigger asChild>
<FilledButton
size="large"
label="Copy link"
startIcon={copyIcon}
onClick={copyRoomLink}
/>
</Popover.Trigger>
<Popover.Content
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="ShareableLinkDialog__popover"
side="top"
align="end"
sideOffset={5.5}
>
{tablerCheckIcon} copied
</Popover.Content>
</Popover.Root>
</div>
<div className="ShareableLinkDialog__description">
🔒 {t("alerts.uploadedSecurly")}
</div>
</div>
</Dialog>
);
};
+24 -2
View File
@@ -1,4 +1,10 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
@@ -12,6 +18,7 @@ export type TextFieldProps = {
readonly?: boolean;
fullWidth?: boolean;
selectOnRender?: boolean;
label?: string;
placeholder?: string;
@@ -19,13 +26,28 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
{
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return (
<div
className={clsx("ExcTextField", {
+1 -1
View File
@@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed;
z-index: 1000;
z-index: var(--zIndex-popup);
padding: 8px;
border-radius: 6px;
+21 -4
View File
@@ -1608,13 +1608,30 @@ export const tablerCheckIcon = createIcon(
tablerIconProps,
);
export const alertTriangleIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M11 7l6 6"></path>
<path d="M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z"></path>
<path d="m9.168 5.833 5 5M9.999 6.667l-6.667 6.666v3.334h3.333L13.332 10M9.999 6.667l3.083-3.084a.833.833 0 0 1 1.167 0l2.166 2.167a.833.833 0 0 1 0 1.167L13.332 10M9.999 6.667 13.332 10" />
<rect
x="13.684"
y="2.976"
width="4.751"
height="5.106"
rx="1"
transform="rotate(45 13.684 2.976)"
fill="currentColor"
/>
</g>,
tablerIconProps,
modifiedTablerIconProps,
);
export const extraToolsIcon = createIcon(
+29 -2
View File
@@ -1,6 +1,10 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
} from "../App";
import {
ExportIcon,
ExportImageIcon,
@@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
export const LoadScene = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const elements = useExcalidrawElements();
if (!actionManager.isActionEnabled(actionLoadScene)) {
return null;
}
const handleSelect = async () => {
if (
!elements.length ||
(await openConfirmModal({
title: t("overwriteConfirm.modal.loadFromFile.title"),
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
color: "warning",
description: (
<Trans
i18nKey="overwriteConfirm.modal.loadFromFile.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
}))
) {
actionManager.executeAction(actionLoadScene);
}
};
return (
<DropdownMenuItem
icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)}
onSelect={handleSelect}
data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")}
+2
View File
@@ -12,6 +12,7 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
+9
View File
@@ -5,6 +5,15 @@
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
--zIndex-toast: 999999;
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
}
.excalidraw {
+45 -4
View File
@@ -27,10 +27,6 @@
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
@@ -99,9 +95,33 @@
--color-gray-100: #121212;
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
--color-warning-darkest: #ec8b14;
--color-text-warning: var(--text-primary-color);
--color-danger: #db6965;
--color-danger-dark: #db6965;
--color-danger-darker: #d65550;
--color-danger-darkest: #d1413c;
--color-danger-text: black;
--color-danger-background: #fff0f0;
--color-danger-icon-background: #ffdad6;
--color-danger-color: #700000;
--color-danger-icon-color: #700000;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--text-primary-color);
--color-warning-icon-color: var(--text-primary-color);
--color-muted: var(--color-gray-30);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-100);
--color-muted-background: var(--color-gray-80);
--color-muted-background-darker: var(--color-gray-100);
--color-promo: #e70078;
--color-success: #268029;
--color-success-lighter: #cafccc;
@@ -177,6 +197,27 @@
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
--color-danger-dark: #672120;
--color-danger-darker: #8f2625;
--color-danger-darkest: #ac2b29;
--color-danger-text: #fbcbcc;
--color-danger-background: #fbcbcc;
--color-danger-icon-background: #672120;
--color-danger-color: #261919;
--color-danger-icon-color: #fbcbcc;
--color-warning-background: var(--color-warning);
--color-warning-icon-background: var(--color-warning-dark);
--color-warning-color: var(--color-gray-80);
--color-warning-icon-color: var(--color-gray-80);
--color-muted: var(--color-gray-80);
--color-muted-darker: var(--color-gray-60);
--color-muted-darkest: var(--color-gray-20);
--color-muted-background: var(--color-gray-40);
--color-muted-background-darker: var(--color-gray-20);
--color-promo: #d297ff;
}
}
+24 -1
View File
@@ -41,6 +41,7 @@ import {
measureBaseline,
} from "../element/textElement";
import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit<
AppState,
@@ -142,7 +143,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false,
};
@@ -370,6 +371,24 @@ const repairBoundElement = (
}
};
/**
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (!containingFrame) {
element.frameId = null;
}
}
};
export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
@@ -410,6 +429,10 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
+30
View File
@@ -0,0 +1,30 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
+9 -17
View File
@@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../analytics";
@@ -166,7 +167,7 @@ export const Hyperlink = ({
/>
) : (
<a
href={element.link || ""}
href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
@@ -177,7 +178,13 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK,
event.nativeEvent,
);
onLinkOpen(element, customEvent);
onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
if (customEvent.defaultPrevented) {
event.preventDefault();
}
@@ -231,21 +238,6 @@ const getCoordsForPopover = (
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
-1
View File
@@ -862,7 +862,6 @@ const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
"diamond",
"image",
"arrow",
]);
+49
View File
@@ -1459,5 +1459,54 @@ describe("textWysiwyg", () => {
}),
);
});
it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
expect(h.elements.length).toBe(1);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.MIDDLE);
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.BOTTOM);
// Attempt to Bind 2nd text using text tool
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
editor.blur();
expect(h.elements.length).toBe(3);
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(null);
expect(text.text).toBe("Excalidraw");
});
});
});
+9 -9
View File
@@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
@@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
),
).toBeFalsy();
});
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
-1
View File
@@ -126,7 +126,6 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element))
);
};
-1
View File
@@ -162,7 +162,6 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = {
@@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async (
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
+14 -6
View File
@@ -282,11 +282,15 @@ export const loadScene = async (
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
) => {
): Promise<ExportToBackendResult> => {
const encryptionKey = await generateEncryptionKey("string");
const payload = await compressData(
@@ -327,14 +331,18 @@ export const exportToBackend = async (
files: filesToUpload,
});
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
return { url: urlString, errorMessage: null };
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
return {
url: null,
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
};
}
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} catch (error: any) {
console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
}
};
+67 -9
View File
@@ -69,7 +69,10 @@ import {
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
@@ -88,6 +91,10 @@ import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../utility-types";
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../components/Trans";
polyfill();
@@ -98,6 +105,19 @@ languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
<Trans
i18nKey="overwriteConfirm.modal.shareableLink.description"
bold={(text) => <strong>{text}</strong>}
br={() => <br />}
/>
),
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
color: "danger",
} as const;
const initializeScene = async (opts: {
collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI;
@@ -129,7 +149,7 @@ const initializeScene = async (opts: {
// don't prompt for collab scenes because we don't override local storage
roomLinkData ||
// otherwise, prompt whether user wants to override current scene
window.confirm(t("alerts.loadSceneOverridePrompt"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
@@ -168,7 +188,7 @@ const initializeScene = async (opts: {
const data = await loadFromBlob(await request.blob(), null, null);
if (
!scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
(await openConfirmModal(shareableLinkConfirmDialog))
) {
return { scene: data, isExternalScene };
}
@@ -554,6 +574,10 @@ const ExcalidrawWrapper = () => {
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
@@ -565,7 +589,7 @@ const ExcalidrawWrapper = () => {
}
if (canvas) {
try {
await exportToBackend(
const { url, errorMessage } = await exportToBackend(
exportedElements,
{
...appState,
@@ -575,6 +599,14 @@ const ExcalidrawWrapper = () => {
},
files,
);
if (errorMessage) {
setErrorMessage(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -674,21 +706,47 @@ const ExcalidrawWrapper = () => {
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
onClick={() => {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
}}
>
{t("overwriteConfirm.action.excalidrawPlus.description")}
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div>
);
};
+5 -2
View File
@@ -304,7 +304,7 @@ export const groupsAreCompletelyOutOfFrame = (
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
@@ -591,6 +591,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => {
if (
element.frameId &&
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
@@ -598,7 +599,9 @@ export const updateFrameMembershipOfSelectedElements = (
}
});
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
return elementsToRemove.size > 0
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
: allElements;
};
/**
+1
View File
@@ -10,6 +10,7 @@ export const CODES = {
BRACKET_LEFT: "BracketLeft",
ONE: "Digit1",
TWO: "Digit2",
THREE: "Digit3",
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "إجراءات اللوحة",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Действия по платното",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "প্রকাশিত",
"sidebarLock": "লক",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Manté la barra lateral oberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador",
"hand": "Mà (eina de desplaçament)"
"frame": "",
"hand": "Mà (eina de desplaçament)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accions del llenç",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vyberte barvu z plátna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Režim Pera - zabránit dotyku",
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
"eraser": "Guma",
"hand": "Ruka (nástroj pro posouvání)"
"frame": "",
"hand": "Ruka (nástroj pro posouvání)",
"extraTools": ""
},
"headings": {
"canvasActions": "Akce plátna",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Veröffentlicht",
"sidebarLock": "Seitenleiste offen lassen",
"selectAllElementsInFrame": "Alle Elemente im Rahmen auswählen",
"removeAllElementsFromFrame": "Alle Elemente aus dem Rahmen entfernen",
"eyeDropper": "Farbe von der Zeichenfläche auswählen"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Stift-Modus - Berührung verhindern",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer",
"hand": "Hand (Schwenkwerkzeug)"
"frame": "Rahmenwerkzeug",
"hand": "Hand (Schwenkwerkzeug)",
"extraTools": "Weitere Werkzeuge"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Δημοσιευμένο",
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
"eraser": "Γόμα",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
+31
View File
@@ -449,5 +449,36 @@
"shades": "Shades",
"hexCode": "Hex code",
"noShades": "No shades available for this color"
},
"overwriteConfirm": {
"action": {
"exportToImage": {
"title": "Export as image",
"button": "Export as image",
"description": "Export the scene data as an image from which you can import later."
},
"saveToDisk": {
"title": "Save to disk",
"button": "Save to disk",
"description": "Export the scene data to a file from which you can import later."
},
"excalidrawPlus": {
"title": "Excalidraw+",
"button": "Export to Excalidraw+",
"description": "Save the scene to your Excalidraw+ workspace."
}
},
"modal": {
"loadFromFile": {
"title": "Load from file",
"button": "Load from file",
"description": "Loading from a file will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first using one of the options below."
},
"shareableLink": {
"title": "Load from link",
"button": "Replace my content",
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Mantener barra lateral abierta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo Lápiz - previene toque",
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar",
"hand": "Mano (herramienta de panoramización)"
"frame": "",
"hand": "Mano (herramienta de panoramización)",
"extraTools": ""
},
"headings": {
"canvasActions": "Acciones del lienzo",
+39 -35
View File
@@ -124,7 +124,9 @@
},
"statusPublished": "Argitaratua",
"sidebarLock": "Mantendu alboko barra irekita",
"eyeDropper": ""
"selectAllElementsInFrame": "Hautatu markoko elementu guztiak",
"removeAllElementsFromFrame": "Kendu markoko elementu guztiak",
"eyeDropper": "Aukeratu kolorea oihaletik"
},
"library": {
"noItems": "Oraindik ez da elementurik gehitu...",
@@ -221,7 +223,9 @@
"penMode": "Luma modua - ukipena saihestu",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma",
"hand": "Eskua (panoratze tresna)"
"frame": "Marko tresna",
"hand": "Eskua (panoratze tresna)",
"extraTools": "Tresna gehiago"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "Kendu hautatutako elementuak liburutegitik"
},
"imageExportDialog": {
"header": "",
"header": "Esportatu irudia",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "Atzeko planoa",
"onlySelected": "Hautapena soilik",
"darkMode": "Modu iluna",
"embedScene": "Txertatu eszena",
"scale": "Eskala",
"padding": "Betegarria"
},
"tooltip": {
"embedScene": ""
"embedScene": "Eszenaren datuak esportatutako PNG/SVG fitxategian gordeko dira, eszena bertatik berrezartzeko.\nEsportatutako fitxategien tamaina handituko da."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "Esportatu PNG gisa",
"exportToSvg": "Esportatu SVG gisa",
"copyPngToClipboard": "Kopiatu PNG arbelera"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "Kopiatu arbelean"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "Gardena",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "Beltza",
"white": "Zuria",
"red": "Gorria",
"pink": "Arrosa",
"grape": "Mahats kolorea",
"violet": "Bioleta",
"gray": "Grisa",
"blue": "Urdina",
"cyan": "Ziana",
"teal": "Berde urdinxka",
"green": "Berdea",
"yellow": "Horia",
"orange": "Laranja",
"bronze": "Brontzea"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "Gehien erabilitako kolore pertsonalizatuak",
"colors": "Koloreak",
"shades": "Ñabardurak",
"hexCode": "Hez kodea",
"noShades": "Kolore honetarako ez dago ñabardurarik eskuragarri"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "منتشر شده",
"sidebarLock": "باز نگه داشتن سایدبار",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "حالت قلم - جلوگیری از تماس",
"link": "افزودن/به‌روزرسانی پیوند برای شکل انتخابی",
"eraser": "پاک کن",
"hand": "دست (ابزار پانینگ)"
"frame": "",
"hand": "دست (ابزار پانینگ)",
"extraTools": ""
},
"headings": {
"canvasActions": "عملیات روی بوم",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Julkaistu",
"sidebarLock": "Pidä sivupalkki avoinna",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Kynätila - estä kosketus",
"link": "Lisää/päivitä linkki valitulle muodolle",
"eraser": "Poistotyökalu",
"hand": "Käsi (panning-työkalu)"
"frame": "",
"hand": "Käsi (panning-työkalu)",
"extraTools": ""
},
"headings": {
"canvasActions": "Piirtoalueen toiminnot",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publié",
"sidebarLock": "Maintenir la barre latérale ouverte",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode stylo - évite le toucher",
"link": "Ajouter/mettre à jour le lien pour une forme sélectionnée",
"eraser": "Gomme",
"hand": "Mains (outil de déplacement de la vue)"
"frame": "",
"hand": "Mains (outil de déplacement de la vue)",
"extraTools": ""
},
"headings": {
"canvasActions": "Actions du canevas",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo lapis - evitar o contacto",
"link": "Engadir/ Actualizar ligazón para a forma seleccionada",
"eraser": "Goma de borrar",
"hand": "Man (ferramenta de desprazamento)"
"frame": "",
"hand": "Man (ferramenta de desprazamento)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accións do lenzo",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "פורסם",
"sidebarLock": "שמור את סרגל הצד פתוח",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "מצב עט - מנע נגיעה",
"link": "הוספה/עדכון קישור של הצורה שנבחרה",
"eraser": "מחק",
"hand": "יד (כלי הזזה)"
"frame": "",
"hand": "יד (כלי הזזה)",
"extraTools": ""
},
"headings": {
"canvasActions": "פעולות קנבאס",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "प्रकाशित",
"sidebarLock": "साइडबार खुला रखे.",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलक से रंग चुने"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "पेन का मोड - स्पर्श टाले",
"link": "",
"eraser": "रबड़",
"hand": "हाथ ( खिसकाने का औज़ार)"
"frame": "",
"hand": "हाथ ( खिसकाने का औज़ार)",
"extraTools": ""
},
"headings": {
"canvasActions": "कैनवास क्रिया",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Vászon műveletek",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Telah terbit",
"sidebarLock": "Biarkan sidebar tetap terbuka",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mode pena - mencegah sentuhan",
"link": "Tambah/Perbarui tautan untuk bentuk yang dipilih",
"eraser": "Penghapus",
"hand": "Tangan (alat panning)"
"frame": "",
"hand": "Tangan (alat panning)",
"extraTools": ""
},
"headings": {
"canvasActions": "Opsi Kanvas",
+7 -3
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Pubblicato",
"sidebarLock": "Mantieni aperta la barra laterale",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modalità penna - previene il tocco",
"link": "Aggiungi/ aggiorna il link per una forma selezionata",
"eraser": "Gomma",
"hand": "Mano (strumento di panoramica)"
"frame": "",
"hand": "Mano (strumento di panoramica)",
"extraTools": "Altri strumenti"
},
"headings": {
"canvasActions": "Azioni sulla Tela",
@@ -361,12 +365,12 @@
"withBackground": "Sfondo",
"onlySelected": "Solo selezionato",
"darkMode": "Tema scuro",
"embedScene": "",
"embedScene": "Includi scena",
"scale": "Scala",
"padding": ""
},
"tooltip": {
"embedScene": ""
"embedScene": "I dati della scena saranno salvati nel file PNG/SVG esportato in modo che la scena possa essere ripristinata da esso.\nQuesto aumenterà la dimensione del file esportato."
},
"title": {
"exportToPng": "Esporta come PNG",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "公開済み",
"sidebarLock": "サイドバーを開いたままにする",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "ペンモード - タッチ防止",
"link": "選択した図形のリンクを追加/更新",
"eraser": "消しゴム",
"hand": "手 (パンニングツール)"
"frame": "",
"hand": "手 (パンニングツール)",
"extraTools": ""
},
"headings": {
"canvasActions": "キャンバス操作",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "Óshirgish",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Yeffeɣ-d",
"sidebarLock": "Eǧǧ afeggag n yidis yeldi",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Askar n yimru - gdel tanalit",
"link": "Rnu/leqqem aseɣwen i talɣa yettwafernen",
"eraser": "Sfeḍ",
"hand": "Afus (afecku n usmutti n tmuɣli)"
"frame": "",
"hand": "Afus (afecku n usmutti n tmuɣli)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tigawin n teɣzut n usuneɣ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "ត្រូវបានបោះពុម្ពផ្សាយ",
"sidebarLock": "ទុករបារចំហៀងបើក",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "របៀបប៊ិច - ជៀសវាងការប៉ះ",
"link": "បន្ថែម/ធ្វើបច្ចុប្បន្នភាពតំណភ្ជាប់សម្រាប់រូបរាងដែលបានជ្រើសរើស",
"eraser": "ជ័រលុប",
"hand": "ដៃ (panning tool)"
"frame": "",
"hand": "ដៃ (panning tool)",
"extraTools": ""
},
"headings": {
"canvasActions": "សកម្មភាពបាវ",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "게시됨",
"sidebarLock": "사이드바 유지",
"selectAllElementsInFrame": "프레임의 모든 요소 선택",
"removeAllElementsFromFrame": "프레임의 모든 요소 삭제",
"eyeDropper": "캔버스에서 색상 고르기"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "펜 모드 - 터치 방지",
"link": "선택한 도형에 대해서 링크를 추가/업데이트",
"eraser": "지우개",
"hand": "손 (패닝 도구)"
"frame": "프레임 도구",
"hand": "손 (패닝 도구)",
"extraTools": "다른 도구"
},
"headings": {
"canvasActions": "캔버스 동작",
+40 -36
View File
@@ -71,7 +71,7 @@
"language": "زمان",
"liveCollaboration": "هاوکاریکردنی زیندو...",
"duplicateSelection": "لەبەرگرتنەوە",
"untitled": "بێ-ناو",
"untitled": "بێ ناونیشان",
"name": "ناو",
"yourName": "ناوەکەت",
"madeWithExcalidraw": "دروستکراوە بە Excalidraw",
@@ -124,7 +124,9 @@
},
"statusPublished": "بڵاوکراوەتەوە",
"sidebarLock": "هێشتنەوەی شریتی لا بە کراوەیی",
"eyeDropper": ""
"selectAllElementsInFrame": "هەموو توخمەکانی ناو چوارچێوەکە دیاری بکە",
"removeAllElementsFromFrame": "هەموو توخمەکانی ناو چوارچێوەکە لابەرە",
"eyeDropper": "ڕەنگێک لەسەر تابلۆکە هەڵبژێرە"
},
"library": {
"noItems": "هێشتا هیچ بڕگەیەک زیاد نەکراوە...",
@@ -221,7 +223,9 @@
"penMode": "شێوازی قەڵەم - دەست لێدان ڕابگرە",
"link": "زیادکردن/ نوێکردنەوەی لینک بۆ شێوەی دیاریکراو",
"eraser": "سڕەر",
"hand": "دەست (ئامرازی پانکردن)"
"frame": "ئامرازی چوارچێوە",
"hand": "دەست (ئامرازی پانکردن)",
"extraTools": "ئامرازی زیاتر"
},
"headings": {
"canvasActions": "کردارەکانی تابلۆ",
@@ -356,27 +360,27 @@
"removeItemsFromLib": "لابردنی ئایتمە دیاریکراوەکان لە کتێبخانە"
},
"imageExportDialog": {
"header": "",
"header": "وێنە هەناردە بکە",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
"withBackground": "پاشبنەما",
"onlySelected": "تەنها دیاریکراوەکان",
"darkMode": "دۆخی تاریک",
"embedScene": "دیمەنەکە بەکاربهێنەرەوە",
"scale": "قەبارە",
"padding": "بۆشایی"
},
"tooltip": {
"embedScene": ""
"embedScene": "داتاکانی دیمەنەکە لە فایلە هەناردەکراوەکەی PNG/SVG هەڵدەگیرێن بۆ ئەوەی دیمەنەکە لێیەوە بگەڕێتەوە.\nقەبارەی پەڕگەی هەناردەکراو زیاد دەکات."
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "هەناردە بکە وەک PNG",
"exportToSvg": "هەناردە بکە وەک SVG",
"copyPngToClipboard": "لەبەربگرەوە بۆ سەر تەختەنوس"
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
"exportToPng": "PNG",
"exportToSvg": "SVG",
"copyPngToClipboard": "له‌به‌ری بگره‌وه‌ بۆ ته‌خته‌نووس"
}
},
"encrypted": {
@@ -411,20 +415,20 @@
},
"colors": {
"transparent": "ڕوون",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
"black": "ڕەش",
"white": "سپی",
"red": "سور",
"pink": "پەمەیی",
"grape": "مێوژی",
"violet": "مۆری کاڵ",
"gray": "خۆڵەمێشی",
"blue": "شین",
"cyan": "شینی ئاسمانی",
"teal": "شەدری",
"green": "سه‌وز",
"yellow": "زەرد",
"orange": "پرتەقاڵی",
"bronze": "برۆنزی"
},
"welcomeScreen": {
"app": {
@@ -440,10 +444,10 @@
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
"mostUsedCustomColors": "زۆرترین ڕەنگە باوە بەکارهاتووەکان",
"colors": "ڕەنگەکان",
"shades": "سێبەرەکان",
"hexCode": "کۆدی هێکس",
"noShades": "هیچ سێبەرێک بۆ ئەم ڕەنگە بەردەست نییە"
}
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Rašyklio režimas - neleisti prisilietimų",
"link": "Pridėti / Atnaujinti pasirinktos figūros nuorodą",
"eraser": "Trintukas",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Veiksmai su drobe",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicēts",
"sidebarLock": "Paturēt atvērtu sānjoslu",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pildspalvas režīms novērst pieskaršanos",
"link": "Pievienot/rediģēt atlasītās figūras saiti",
"eraser": "Dzēšgumija",
"hand": "Roka (panoramēšanas rīks)"
"frame": "",
"hand": "Roka (panoramēšanas rīks)",
"extraTools": ""
},
"headings": {
"canvasActions": "Tāfeles darbības",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "प्रकाशित करा",
"sidebarLock": "साइडबार उघडं ठेवा",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "चित्रफलकातून रंग निवडा"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "पेन चा मोड - स्पर्श टाळा",
"link": "निवडलेल्या आकारासाठी दुवा जोडा/बदल करा",
"eraser": "खोड रबर",
"hand": "हात ( सरकवण्या चे उपकरण)"
"frame": "",
"hand": "हात ( सरकवण्या चे उपकरण)",
"extraTools": ""
},
"headings": {
"canvasActions": "पटल क्रिया",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ကားချပ်လုပ်ဆောင်ချက်",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publisert",
"sidebarLock": "Holde sidemenyen åpen",
"selectAllElementsInFrame": "Velg alle elementene i rammen",
"removeAllElementsFromFrame": "Fjern alle elementer fra rammen",
"eyeDropper": "Velg farge fra lerretet"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pennemodus - forhindre berøring",
"link": "Legg til / oppdater link for en valgt figur",
"eraser": "Viskelær",
"hand": "Hånd (panoreringsverktøy)"
"frame": "Rammeverktøy",
"hand": "Hånd (panoreringsverktøy)",
"extraTools": "Flere verktøy"
},
"headings": {
"canvasActions": "Handlinger: lerret",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Gepubliceerd",
"sidebarLock": "Zijbalk open houden",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pen modus - Blokkeer aanraken",
"link": "Link toevoegen / bijwerken voor een geselecteerde vorm",
"eraser": "Gum",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Canvasacties",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "Legg til/ oppdater lenke til valt figur",
"eraser": "Viskelêr",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Handlingar: lerret",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Gardar la barra laterala dobèrta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mòde estilo - empachar lo contact",
"link": "Apondre/Actualizar lo ligam per una fòrma seleccionada",
"eraser": "Goma",
"hand": "Man (aisina de desplaçament de la vista)"
"frame": "",
"hand": "Man (aisina de desplaçament de la vista)",
"extraTools": ""
},
"headings": {
"canvasActions": "Accions del canabàs",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "ਪ੍ਰਕਾਸ਼ਤ ਹੈ",
"sidebarLock": "ਸਾਈਡਬਾਰ ਨੂੰ ਖੁੱਲ੍ਹਾ ਰੱਖੋ",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "ਪੈੱਨ ਮੋਡ - ਟੱਚ ਤੋਂ ਬਚਾਉਂਦਾ ਹੈ",
"link": "",
"eraser": "ਰਬੜ",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "ਕੈਨਵਸ ਦੀਆਂ ਕਾਰਵਾਈਆਂ",
+36 -36
View File
@@ -1,56 +1,56 @@
{
"ar-SA": 80,
"ar-SA": 79,
"az-AZ": 20,
"bg-BG": 54,
"bn-BD": 60,
"ca-ES": 88,
"cs-CZ": 100,
"bn-BD": 59,
"ca-ES": 87,
"cs-CZ": 98,
"da-DK": 33,
"de-DE": 100,
"el-GR": 93,
"el-GR": 92,
"en": 100,
"es-ES": 89,
"eu-ES": 90,
"fa-IR": 89,
"fi-FI": 88,
"fr-FR": 89,
"gl-ES": 89,
"he-IL": 87,
"hi-IN": 78,
"es-ES": 88,
"eu-ES": 100,
"fa-IR": 88,
"fi-FI": 87,
"fr-FR": 88,
"gl-ES": 88,
"he-IL": 86,
"hi-IN": 77,
"hu-HU": 76,
"id-ID": 95,
"id-ID": 94,
"it-IT": 98,
"ja-JP": 89,
"ja-JP": 88,
"kaa": 18,
"kab-KAB": 88,
"kab-KAB": 87,
"kk-KZ": 21,
"km-KH": 95,
"km-KH": 94,
"ko-KR": 100,
"ku-TR": 90,
"lt-LT": 56,
"lv-LV": 89,
"mr-IN": 96,
"my-MM": 41,
"ku-TR": 100,
"lt-LT": 55,
"lv-LV": 88,
"mr-IN": 95,
"my-MM": 40,
"nb-NO": 100,
"nl-NL": 83,
"nn-NO": 77,
"oc-FR": 87,
"pa-IN": 90,
"pl-PL": 100,
"pt-BR": 99,
"pt-PT": 95,
"ro-RO": 100,
"ru-RU": 100,
"nn-NO": 76,
"oc-FR": 86,
"pa-IN": 89,
"pl-PL": 98,
"pt-BR": 98,
"pt-PT": 94,
"ro-RO": 98,
"ru-RU": 98,
"si-LK": 9,
"sk-SK": 100,
"sk-SK": 98,
"sl-SI": 100,
"sv-SE": 100,
"ta-IN": 85,
"th-TH": 39,
"tr-TR": 87,
"uk-UA": 97,
"ta-IN": 84,
"th-TH": 38,
"tr-TR": 86,
"uk-UA": 96,
"vi-VN": 56,
"zh-CN": 100,
"zh-HK": 26,
"zh-HK": 25,
"zh-TW": 100
}
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Opublikowano",
"sidebarLock": "Panel boczny zawsze otwarty",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Wybierz kolor z płótna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Tryb pióra — zapobiegaj dotknięciom",
"link": "Dodaj/aktualizuj link dla wybranego kształtu",
"eraser": "Gumka",
"hand": "Ręka (narzędzie do przesuwania)"
"frame": "",
"hand": "Ręka (narzędzie do przesuwania)",
"extraTools": ""
},
"headings": {
"canvasActions": "Narzędzia",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo caneta — impede o toque",
"link": "Adicionar/Atualizar link para uma forma selecionada",
"eraser": "Borracha",
"hand": "Mão (ferramenta de rolagem)"
"frame": "",
"hand": "Mão (ferramenta de rolagem)",
"extraTools": ""
},
"headings": {
"canvasActions": "Ações da tela",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicado",
"sidebarLock": "Manter a barra lateral aberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Modo caneta - impedir toque",
"link": "Acrescentar/ Adicionar ligação para uma forma seleccionada",
"eraser": "Borracha",
"hand": "Mão (ferramenta de movimento da tela)"
"frame": "",
"hand": "Mão (ferramenta de movimento da tela)",
"extraTools": ""
},
"headings": {
"canvasActions": "Ações da área de desenho",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicat",
"sidebarLock": "Păstrează deschisă bara laterală",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Alegere culoare din pânză"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Mod stilou împiedică atingerea",
"link": "Adăugare/actualizare URL pentru forma selectată",
"eraser": "Radieră",
"hand": "Mână (instrument de panoramare)"
"frame": "",
"hand": "Mână (instrument de panoramare)",
"extraTools": ""
},
"headings": {
"canvasActions": "Acțiuni pentru pânză",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Опубликовано",
"sidebarLock": "Держать боковую панель открытой",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Взять образец цвета с холста"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Режим пера - предотвращение касания",
"link": "Добавить/обновить ссылку для выбранной фигуры",
"eraser": "Ластик",
"hand": "Рука (перемещение холста)"
"frame": "",
"hand": "Рука (перемещение холста)",
"extraTools": ""
},
"headings": {
"canvasActions": "Операции холста",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Zverejnené",
"sidebarLock": "Nechať bočný panel otvorený",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vybrať farbu z plátna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Režim pera zabrániť dotyku",
"link": "Pridať/ Upraviť odkaz pre vybraný tvar",
"eraser": "Guma",
"hand": "Ruka (nástroj pre pohyb plátna)"
"frame": "",
"hand": "Ruka (nástroj pre pohyb plátna)",
"extraTools": ""
},
"headings": {
"canvasActions": "Akcie plátna",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Objavljeno",
"sidebarLock": "Obdrži stransko vrstico odprto",
"selectAllElementsInFrame": "Izberi vse elemente v okvirju",
"removeAllElementsFromFrame": "Izbriši vse elemente v okvirju",
"eyeDropper": "Izberi barvo s platna"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Način peresa - prepreči dotik",
"link": "Dodaj/posodobi povezavo za izbrano obliko",
"eraser": "Radirka",
"hand": "Roka (orodje za premikanje)"
"frame": "Okvir",
"hand": "Roka (orodje za premikanje)",
"extraTools": "Več orodij"
},
"headings": {
"canvasActions": "Dejanja za platno",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Publicerad",
"sidebarLock": "Håll sidofältet öppet",
"selectAllElementsInFrame": "Markera alla element i rutan",
"removeAllElementsFromFrame": "Ta bort alla element från rutan",
"eyeDropper": "Välj färg från canvas"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Pennläge - förhindra touch",
"link": "Lägg till / Uppdatera länk för en vald form",
"eraser": "Radergummi",
"hand": "Hand (panoreringsverktyg)"
"frame": "Rutverktyg",
"hand": "Hand (panoreringsverktyg)",
"extraTools": "Fler verktyg"
},
"headings": {
"canvasActions": "Canvas-åtgärder",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "வெளியிடப்பட்டது",
"sidebarLock": "பக்கப்பட்டையைத் திறந்தே வை",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "கித்தானிலிருந்து நிறம் தேர்ந்தெடு"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "தேர்தெடுத்த வடிவத்திற்குத் தொடுப்பைச் சேர்/ புதுப்பி",
"eraser": "அழிப்பி",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "கித்தான் செயல்கள்",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "เผยแพร่",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "ยางลบ",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Yayınlandı",
"sidebarLock": "Kenar çubuğu açık kalsın",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Kalem modu - dokunmayı engelle",
"link": "Seçilen şekil için bağlantı Ekle/Güncelle",
"eraser": "Silgi",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "Tuval eylemleri",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Опубліковано",
"sidebarLock": "Не закривати бокове меню",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Режим пера - запобігання дотику",
"link": "Додати/Оновити посилання для вибраної форми",
"eraser": "Очищувач",
"hand": "Рука (інструмент для панорамування)"
"frame": "",
"hand": "Рука (інструмент для панорамування)",
"extraTools": ""
},
"headings": {
"canvasActions": "Дії з полотном",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "Đã đăng tải",
"sidebarLock": "Giữ thanh bên luôn mở",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "Chế độ bút vẽ - ngăn ngừa chạm nhầm",
"link": "Thêm/ Chỉnh sửa liên kết cho hình được chọn",
"eraser": "Xóa",
"hand": "Tay kéo"
"frame": "",
"hand": "Tay kéo",
"extraTools": ""
},
"headings": {
"canvasActions": "Hành động canvas",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "已发布",
"sidebarLock": "侧边栏常驻",
"selectAllElementsInFrame": "选择画框中的所有元素",
"removeAllElementsFromFrame": "删除画框中的所有元素",
"eyeDropper": "从画布上取色"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "笔模式 避免误触",
"link": "为选中的形状添加/更新链接",
"eraser": "橡皮",
"hand": "抓手(平移工具"
"frame": "画框工具",
"hand": "抓手(平移工具)",
"extraTools": "更多工具"
},
"headings": {
"canvasActions": "画布动作",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "",
"sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": ""
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
"frame": "",
"hand": "",
"extraTools": ""
},
"headings": {
"canvasActions": "畫布動作",
+5 -1
View File
@@ -124,6 +124,8 @@
},
"statusPublished": "已發布",
"sidebarLock": "側欄維持開啟",
"selectAllElementsInFrame": "選取框架內的所有元素",
"removeAllElementsFromFrame": "從框架內移除所有元素",
"eyeDropper": "從畫布中選取顏色"
},
"library": {
@@ -221,7 +223,9 @@
"penMode": "筆模式 - 避免觸摸",
"link": "為所選的形狀增加\b/更新連結",
"eraser": "橡皮擦",
"hand": "手形(平移工具"
"frame": "框架工具",
"hand": "手形(平移工具)",
"extraTools": "更多工具"
},
"headings": {
"canvasActions": "canvas 動作",
+3 -1
View File
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
@@ -63,7 +65,7 @@ Please add the latest change on the top under the correct section.
### Features
- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
- [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
+72 -1
View File
@@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
<div className="export export-blob">
<img src={blobUrl} alt="" />
</div>
<button
onClick={async () => {
if (!excalidrawAPI) {
@@ -806,6 +805,78 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
>
Export to Canvas
</button>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
>
Export to Canvas
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToViewport: true,
});
}}
>
Fit to viewport, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
}}
>
Fit to content, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0]);
}}
>
Scroll to first element, no fitToContent, no fitToViewport
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
+2
View File
@@ -247,3 +247,5 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url";

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