Compare commits

..
Author SHA1 Message Date
Arnošt Pleskot b3d95d9307 feat: use upng 2023-06-16 23:49:28 +02:00
Arnošt Pleskot 671ed94d74 chore: add timers 2023-06-16 23:42:45 +02:00
Arnošt Pleskot d5ac76d4ea feat: working export with pngjs 2023-06-16 22:36:24 +02:00
Arnošt Pleskot 2b19d53549 feat: init of generating blobs in chunk 2023-06-14 11:52:16 +02:00
113 changed files with 850 additions and 5497 deletions
+3 -3
View File
@@ -19,7 +19,6 @@
] ]
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
@@ -28,7 +27,6 @@
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
@@ -55,6 +53,7 @@
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"tunnel-rat": "0.1.2", "tunnel-rat": "0.1.2",
"upng-js": "2.1.0",
"workbox-background-sync": "^6.5.4", "workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4", "workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4", "workbox-cacheable-response": "^6.5.4",
@@ -80,6 +79,7 @@
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.7",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"@types/upng-js": "2.1.2",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@@ -108,7 +108,7 @@
"<rootDir>/src/packages/excalidraw/example" "<rootDir>/src/packages/excalidraw/example"
], ],
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
+1 -4
View File
@@ -12,10 +12,7 @@ export const actionAddToLibrary = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
if (selectedElements.some((element) => element.type === "image")) { if (selectedElements.some((element) => element.type === "image")) {
return { return {
+12 -28
View File
@@ -10,7 +10,6 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@@ -18,20 +17,10 @@ import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( const enableActionGroup = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const alignSelectedElements = ( const alignSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -47,16 +36,14 @@ const alignSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements( return elements.map(
elements.map((element) => updatedElementsMap.get(element.id) || element), (element) => updatedElementsMap.get(element.id) || element,
appState,
); );
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -71,7 +58,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -87,7 +74,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -102,7 +88,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -118,7 +104,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -133,7 +118,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -149,7 +134,7 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -164,7 +149,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -180,7 +165,7 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -193,7 +178,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -207,7 +192,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -220,7 +204,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState)} hidden={!enableActionGroup(elements, appState)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
-1
View File
@@ -249,7 +249,6 @@ export const actionWrapTextInContainer = register({
"rectangle", "rectangle",
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId,
}); });
// update bindings // update bindings
+3 -7
View File
@@ -20,8 +20,6 @@ import {
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -208,7 +206,7 @@ export const actionResetZoom = register({
}); });
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: Bounds, bounds: [number, number, number, number],
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
@@ -236,10 +234,8 @@ export const zoomToFitElements = (
const commonBounds = const commonBounds =
zoomToSelection && selectedElements.length > 0 zoomToSelection && selectedElements.length > 0
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements)) ? getCommonBounds(selectedElements)
: getCommonBounds( : getCommonBounds(nonDeletedElements);
excludeElementsInFramesFromSelection(nonDeletedElements),
);
const newZoom = { const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, { value: zoomValueToFitBoundsOnViewport(commonBounds, {
+6 -19
View File
@@ -16,12 +16,9 @@ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const elementsToCopy = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, true);
includeBoundTextElement: true,
includeElementsInFrames: true,
});
copyToClipboard(elementsToCopy, app.files); copyToClipboard(selectedElements, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
@@ -78,10 +75,7 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -125,10 +119,7 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@@ -181,9 +172,7 @@ export const copyText = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
},
); );
const text = selectedElements const text = selectedElements
@@ -202,9 +191,7 @@ export const copyText = register({
predicate: (elements, appState) => { predicate: (elements, appState) => {
return ( return (
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, { getSelectedElements(elements, appState, true).some(isTextElement)
includeBoundTextElement: true,
}).some(isTextElement)
); );
}, },
contextItemLabel: "labels.copyText", contextItemLabel: "labels.copyText",
+1 -13
View File
@@ -1,4 +1,4 @@
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -18,23 +18,11 @@ const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if ( if (
isBoundToContainer(el) && isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId] appState.selectedElementIds[el.containerId]
+3 -15
View File
@@ -6,7 +6,6 @@ import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@@ -17,17 +16,7 @@ import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
);
};
const distributeSelectedElements = ( const distributeSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -43,9 +32,8 @@ const distributeSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return updateFrameMembershipOfSelectedElements( return elements.map(
elements.map((element) => updatedElementsMap.get(element.id) || element), (element) => updatedElementsMap.get(element.id) || element,
appState,
); );
}; };
+12 -68
View File
@@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
@@ -20,17 +20,9 @@ import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@@ -102,11 +94,8 @@ const duplicateElements = (
return newElement; return newElement;
}; };
const idsOfElementsToDuplicate = arrayToMap( const selectedElementIds = arrayToMap(
getSelectedElements(sortedElements, appState, { getSelectedElements(sortedElements, appState, true),
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
); );
// Ids of elements that have already been processed so we don't push them // Ids of elements that have already been processed so we don't push them
@@ -140,25 +129,12 @@ const duplicateElements = (
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
const isElementAFrame = isFrameElement(element); if (selectedElementIds.get(element.id)) {
// if a group or a container/bound-text, duplicate atomically
if (idsOfElementsToDuplicate.get(element.id)) { if (element.groupIds.length || boundTextElement) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) {
const groupId = getSelectedGroupForElement(appState, element); const groupId = getSelectedGroupForElement(appState, element);
if (groupId) { if (groupId) {
// TODO: const groupElements = getElementsInGroup(sortedElements, groupId);
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameElement(element)
? [...getFrameElements(elements, element.id), element]
: [element],
);
elementsWithClones.push( elementsWithClones.push(
...markAsProcessed([ ...markAsProcessed([
...groupElements, ...groupElements,
@@ -180,34 +156,10 @@ const duplicateElements = (
); );
continue; continue;
} }
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
...elementsInFrame,
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue;
}
}
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well,
// they will have been copied along with the frame atomically in the
// above branch, so we must skip those elements here
//
// now, for elements do not belong any frames or elements whose frames
// are selected (or elements that are left out from the above
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} }
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else { } else {
elementsWithClones.push(...markAsProcessed([element])); elementsWithClones.push(...markAsProcessed([element]));
} }
@@ -248,14 +200,6 @@ const duplicateElements = (
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: finalElements,
@@ -263,7 +207,7 @@ const duplicateElements = (
{ {
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: nextElementsToSelect.reduce( selectedElementIds: newElements.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => { (acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) { if (!isBoundToContainer(element)) {
acc[element.id] = true; acc[element.id] = true;
+4 -17
View File
@@ -11,17 +11,8 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({ export const actionToggleElementLock = register({
name: "toggleElementLock", name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, true);
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (!selectedElements.length) { if (!selectedElements.length) {
return false; return false;
@@ -47,10 +38,8 @@ export const actionToggleElementLock = register({
}; };
}, },
contextItemLabel: (elements, appState) => { contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, { const selected = getSelectedElements(elements, appState, false);
includeBoundTextElement: false, if (selected.length === 1) {
});
if (selected.length === 1 && selected[0].type !== "frame") {
return selected[0].locked return selected[0].locked
? "labels.elementLock.unlock" ? "labels.elementLock.unlock"
: "labels.elementLock.lock"; : "labels.elementLock.lock";
@@ -65,9 +54,7 @@ export const actionToggleElementLock = register({
event.key.toLocaleLowerCase() === KEYS.L && event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
getSelectedElements(elements, appState, { getSelectedElements(elements, appState, false).length > 0
includeBoundTextElement: false,
}).length > 0
); );
}, },
}); });
+2 -12
View File
@@ -12,17 +12,13 @@ import {
isBindingEnabled, isBindingEnabled,
unbindLinearElements, unbindLinearElements,
} from "../element/binding"; } from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: flipSelectedElements(elements, appState, "horizontal"),
flipSelectedElements(elements, appState, "horizontal"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@@ -36,10 +32,7 @@ export const actionFlipVertical = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: flipSelectedElements(elements, appState, "vertical"),
flipSelectedElements(elements, appState, "vertical"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@@ -57,9 +50,6 @@ const flipSelectedElements = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{
includeElementsInFrames: true,
},
); );
const updatedElements = flipElements( const updatedElements = flipElements(
-140
View File
@@ -1,140 +0,0 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { register } from "./register";
const isSingleFrameSelected = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
const elementsInFrame = getFrameElements(
getNonDeletedElements(elements),
selectedFrame.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
elements,
appState: {
...appState,
selectedElementIds: elementsInFrame.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState) => {
const selectedFrame = getSelectedElements(
getNonDeletedElements(elements),
appState,
)[0];
if (selectedFrame && selectedFrame.type === "frame") {
return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
appState: {
...appState,
selectedElementIds: {
[selectedFrame.id]: true,
},
},
commitToHistory: true,
};
}
return {
elements,
appState,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
});
export const actionToggleFrameRendering = register({
name: "toggleFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
return {
elements,
appState: {
...appState,
shouldRenderFrames: !appState.shouldRenderFrames,
},
commitToHistory: false,
};
},
contextItemLabel: "labels.toggleFrameRendering",
checked: (appState: AppState) => appState.shouldRenderFrames,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "frame",
}),
},
commitToHistory: false,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
!event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F,
});
+16 -70
View File
@@ -17,19 +17,9 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@@ -55,9 +45,7 @@ const enableActionGroup = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
},
); );
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@@ -67,13 +55,11 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
},
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
@@ -100,31 +86,9 @@ export const actionGroup = register({
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
} }
let nextElements = [...elements];
// this includes the case where we are grouping elements inside a frame
// and elements outside that frame
const groupingElementsFromDifferentFrames =
new Set(selectedElements.map((element) => element.frameId)).size > 1;
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
nextElements,
elementsInFrame,
appState,
);
});
}
const newGroupId = randomId(); const newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements); const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
nextElements = nextElements.map((element) => {
if (!selectElementIds.get(element.id)) { if (!selectElementIds.get(element.id)) {
return element; return element;
} }
@@ -138,16 +102,17 @@ export const actionGroup = register({
}); });
// keep the z order within the group the same, but move them // keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack // to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId); const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup); const lastGroupElementIndex =
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); updatedElements.lastIndexOf(lastElementInGroup);
const elementsBeforeGroup = nextElements const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
.filter( .filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId), (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
); );
nextElements = [ const updatedElementsInOrder = [
...elementsBeforeGroup, ...elementsBeforeGroup,
...elementsInGroup, ...elementsInGroup,
...elementsAfterGroup, ...elementsAfterGroup,
@@ -157,9 +122,9 @@ export const actionGroup = register({
appState: selectGroup( appState: selectGroup(
newGroupId, newGroupId,
{ ...appState, selectedGroupIds: {} }, { ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements), getNonDeletedElements(updatedElementsInOrder),
), ),
elements: nextElements, elements: updatedElementsInOrder,
commitToHistory: true, commitToHistory: true,
}; };
}, },
@@ -183,23 +148,14 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; return { appState, elements, commitToHistory: false };
} }
let nextElements = [...elements];
const selectedElements = getSelectedElements(nextElements, appState);
const frames = selectedElements
.filter((element) => element.frameId)
.map((element) =>
app.scene.getElement(element.frameId!),
) as ExcalidrawFrameElement[];
const boundTextElementIds: ExcalidrawTextElement["id"][] = []; const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
nextElements = nextElements.map((element) => { const nextElements = elements.map((element) => {
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id); boundTextElementIds.push(element.id);
} }
@@ -220,23 +176,13 @@ export const actionUngroup = register({
getNonDeletedElements(nextElements), getNonDeletedElements(nextElements),
); );
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
frame,
appState,
);
}
});
// remove binded text elements from selection // remove binded text elements from selection
boundTextElementIds.forEach( boundTextElementIds.forEach(
(id) => (updateAppState.selectedElementIds[id] = false), (id) => (updateAppState.selectedElementIds[id] = false),
); );
return { return {
appState: updateAppState, appState: updateAppState,
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
+2 -6
View File
@@ -21,9 +21,7 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
const editingLinearElement = const editingLinearElement =
@@ -42,9 +40,7 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{ true,
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit" ? "labels.lineEditor.exit"
+1
View File
@@ -67,6 +67,7 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({
+4 -3
View File
@@ -1,4 +1,4 @@
import { getClientColor } from "../clients"; import { getClientColors } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@@ -31,14 +31,15 @@ export const actionGoToCollaborator = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator]; const [clientId, collaborator] = data as [string, Collaborator];
const background = getClientColor(clientId); const { background, stroke } = getClientColors(clientId, appState);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""} name={collaborator.username || ""}
src={collaborator.avatarUrl} src={collaborator.avatarUrl}
+1 -4
View File
@@ -102,11 +102,8 @@ const changeProperty = (
includeBoundText = false, includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap( const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, { getSelectedElements(elements, appState, includeBoundText),
includeBoundTextElement: includeBoundText,
}),
); );
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || selectedElementIds.get(element.id) ||
+11 -11
View File
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@@ -14,18 +13,19 @@ export const actionSelectAll = register({
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
} }
const selectedElementIds = elements.reduce(
const selectedElementIds = excludeElementsInFramesFromSelection( (map: Record<ExcalidrawElement["id"], true>, element) => {
elements.filter( if (
(element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked, !element.locked
), ) {
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => { map[element.id] = true;
map[element.id] = true; }
return map; return map;
}, {}); },
{},
);
return { return {
appState: selectGroupsForSelectedElements( appState: selectGroupsForSelectedElements(
+1 -11
View File
@@ -20,7 +20,6 @@ import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement, getDefaultRoundnessTypeForElement,
isFrameElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
@@ -65,9 +64,7 @@ export const actionPasteStyles = register({
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, true);
includeBoundTextElement: true,
});
const selectedElementIds = selectedElements.map((element) => element.id); const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
@@ -130,13 +127,6 @@ export const actionPasteStyles = register({
}); });
} }
if (isFrameElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",
});
}
return newElement; return newElement;
} }
return element; return element;
+11 -36
View File
@@ -2,10 +2,10 @@ import React from "react";
import { import {
Action, Action,
UpdaterFn, UpdaterFn,
ActionName,
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource, ActionSource,
ActionPredicateFn,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
@@ -40,8 +40,7 @@ const trackAction = (
}; };
export class ActionManager { export class ActionManager {
actions = {} as Record<Action["name"], Action>; actions = {} as Record<ActionName, Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -69,12 +68,6 @@ export class ActionManager {
this.app = app; this.app = app;
} }
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
registerAction(action: Action) { registerAction(action: Action) {
this.actions[action.name] = action; this.actions[action.name] = action;
} }
@@ -91,7 +84,7 @@ export class ActionManager {
(action) => (action) =>
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: this.isActionEnabled(action, { noPredicates: true })) && : true) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
@@ -141,7 +134,7 @@ export class ActionManager {
/** /**
* @param data additional data sent to the PanelComponent * @param data additional data sent to the PanelComponent
*/ */
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => { renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions; const canvasActions = this.app.props.UIOptions.canvasActions;
if ( if (
@@ -149,7 +142,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] && "PanelComponent" in this.actions[name] &&
(name in canvasActions (name in canvasActions
? canvasActions[name as keyof typeof canvasActions] ? canvasActions[name as keyof typeof canvasActions]
: this.isActionEnabled(this.actions[name], { noPredicates: true })) : true)
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
@@ -183,31 +176,13 @@ export class ActionManager {
return null; return null;
}; };
isActionEnabled = ( isActionEnabled = (action: Action) => {
action: Action, const elements = this.getElementsIncludingDeleted();
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();
const data = opts?.data;
if ( return (
!opts?.noPredicates && !action.predicate ||
action.predicate && action.predicate(elements, appState, this.app.props, this.app)
!action.predicate(elements, appState, this.app.props, this.app, data) );
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, data)) {
enabled = false;
}
});
return enabled;
}; };
} }
+1 -16
View File
@@ -32,15 +32,6 @@ type ActionFn = (
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
@@ -125,11 +116,6 @@ export type ActionName =
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer"; | "wrapTextInContainer";
export type PanelComponentProps = { export type PanelComponentProps = {
@@ -141,7 +127,7 @@ export type PanelComponentProps = {
}; };
export interface Action { export interface Action {
name: string; name: ActionName;
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
@@ -161,7 +147,6 @@ export interface Action {
appState: AppState, appState: AppState,
appProps: ExcalidrawProps, appProps: ExcalidrawProps,
app: AppClassProperties, app: AppClassProperties,
data?: Record<string, any>,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
+2 -2
View File
@@ -1,6 +1,6 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds"; import { Box, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
export interface Alignment { export interface Alignment {
@@ -33,7 +33,7 @@ export const alignElements = (
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: BoundingBox, selectionBoundingBox: Box,
{ axis, position }: Alignment, { axis, position }: Alignment,
): { x: number; y: number } => { ): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group); const groupBoundingBox = getCommonBoundingBox(group);
-14
View File
@@ -78,16 +78,11 @@ export const getDefaultAppState = (): Omit<
scrollY: 0, scrollY: 0,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
shouldRenderFrames: true,
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null, toast: null,
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false, zenModeEnabled: false,
@@ -181,20 +176,11 @@ const APP_STATE_STORAGE_CONF = (<
scrollY: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false }, selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,
export: false,
server: false,
},
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
shouldRenderFrames: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
elementsToHighlight: { browser: false, export: false, server: false },
toast: { browser: false, export: false, server: false }, toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true }, viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false }, width: { browser: false, export: false, server: false },
+26 -26
View File
@@ -1,31 +1,31 @@
function hashToInteger(id: string) { import {
let hash = 0; DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
if (id.length === 0) { DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
return hash; getAllColorsSpecificShade,
} } from "./colors";
for (let i = 0; i < id.length; i++) { import { AppState } from "./types";
const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
export const getClientColor = ( const BG_COLORS = getAllColorsSpecificShade(
/** DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
* any uniquely identifying key, such as user id or socket id );
*/ const STROKE_COLORS = getAllColorsSpecificShade(
id: string, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
) => { );
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
const saturation = 100;
const lightness = 83;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
}
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
return {
background: BG_COLORS[sum % BG_COLORS.length],
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
};
}; };
/** /**
+1 -20
View File
@@ -7,9 +7,6 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
@@ -60,9 +57,6 @@ export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
let foundFile = false; let foundFile = false;
const _files = elements.reduce((acc, element) => { const _files = elements.reduce((acc, element) => {
@@ -84,20 +78,7 @@ export const copyToClipboard = async (
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => { elements,
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
frameId: null,
});
return copiedElement;
}
return element;
}),
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
+36 -114
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
@@ -35,9 +35,6 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss"; import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -92,8 +89,7 @@ export const SelectedShapeActions = ({
<div> <div>
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image" && commonSelectedType !== "image") ||
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
</div> </div>
@@ -224,78 +220,28 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState; appState: UIAppState;
}) => { }) => (
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); <>
const device = useDevice(); {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
return ( const label = t(`toolBar.${value}`);
<> const letter =
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { key && capitalizeString(typeof key === "string" ? key : key[0]);
const label = t(`toolBar.${value}`); const shortcut = letter
const letter = ? `${letter} ${t("helpDialog.or")} ${numericKey}`
key && capitalizeString(typeof key === "string" ? key : key[0]); : `${numericKey}`;
const shortcut = letter return (
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)} — ${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<ToolButton <ToolButton
className={clsx("Shape", { fillable: false })} className={clsx("Shape", { fillable })}
key={value}
type="radio" type="radio"
icon={frameToolIcon} icon={icon}
checked={activeTool.type === "frame"} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString( title={`${capitalizeString(label)} — ${shortcut}`}
t("toolBar.frame"), keyBindingLabel={numericKey || letter}
)} — ${KEYS.F.toLocaleUpperCase()}`} aria-label={capitalizeString(label)}
keyBindingLabel={KEYS.F.toLocaleUpperCase()} aria-keyshortcuts={shortcut}
aria-label={capitalizeString(t("toolBar.frame"))} data-testid={`toolbar-${value}`}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ setAppState({
@@ -305,54 +251,30 @@ export const ShapesSwitcher = ({
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui"); if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, { const nextActiveTool = updateActiveTool(appState, {
type: "frame", type: value,
}); });
setAppState({ setAppState({
activeTool: nextActiveTool, activeTool: nextActiveTool,
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}} }}
/> />
) : ( );
<DropdownMenu open={isExtraToolsMenuOpen}> })}
<DropdownMenu.Trigger </>
className="App-toolbar__extra-tools-trigger" );
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
export const ZoomActions = ({ export const ZoomActions = ({
renderAction, renderAction,
+134 -904
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -10,9 +10,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $oc-white;
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.625rem;
font-weight: 800; font-weight: 500;
line-height: 1; line-height: 1;
&-img { &-img {
+1
View File
@@ -6,6 +6,7 @@ import { getNameInitial } from "../clients";
type AvatarProps = { type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string; color: string;
border: string;
name: string; name: string;
src?: string; src?: string;
}; };
-1
View File
@@ -164,7 +164,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")} label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut <Shortcut
label={t("labels.eyeDropper")} label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]} shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
+1 -4
View File
@@ -84,10 +84,7 @@ const ImageExportModal = ({
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, { ? getSelectedElements(elements, appState, true)
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements; : elements;
useEffect(() => { useEffect(() => {
+7 -2
View File
@@ -204,7 +204,12 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col gap={6} className={clsx("App-menu_top__left")}> <Stack.Col
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
@@ -249,7 +254,7 @@ const LayerUI = ({
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider"></div>
<HandButton <HandButton
checked={isHandToolActive(appState)} checked={isHandToolActive(appState)}
+1 -5
View File
@@ -148,11 +148,7 @@ const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const create = () => const create = () => getSelectedElements(elements, appState, true);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create()); const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState); const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements); const prevElements = useRef(elements);
+1 -3
View File
@@ -1,6 +1,6 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React, { CSSProperties, useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@@ -25,7 +25,6 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
style?: CSSProperties;
isLoading?: boolean; isLoading?: boolean;
}; };
@@ -115,7 +114,6 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon", "ToolIcon--plain": props.type === "icon",
}, },
)} )}
style={props.style}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
hidden={props.hidden} hidden={props.hidden}
title={props.title} title={props.title}
+1 -18
View File
@@ -15,24 +15,7 @@
height: 1.5rem; height: 1.5rem;
align-self: center; align-self: center;
background-color: var(--default-border-color); background-color: var(--default-border-color);
margin: 0 0.25rem; margin: 0 0.5rem;
} }
} }
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
}
} }
@@ -1,23 +1,23 @@
import clsx from "clsx"; import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
children, children,
onToggle, onToggle,
title,
...rest
}: { }: {
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
title?: string; }) => {
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { const appState = useUIAppState();
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
"zen-mode-transition", "zen-mode-transition",
{ {
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile, "dropdown-menu-button--mobile": device.isMobile,
}, },
).trim(); ).trim();
@@ -28,8 +28,6 @@ const MenuTrigger = ({
onClick={onToggle} onClick={onToggle}
type="button" type="button"
data-testid="dropdown-menu-button" data-testid="dropdown-menu-button"
title={title}
{...rest}
> >
{children} {children}
</button> </button>
@@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2); expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
}); });
@@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2); expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
}); });
@@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2); expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
}); });
@@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2); expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length, queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
).toBe(1); ).toBe(1);
}); });
}); });
-21
View File
@@ -1616,24 +1616,3 @@ export const eyeDropperIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7l16 0"></path>
<path d="M4 17l16 0"></path>
<path d="M7 4l0 16"></path>
<path d="M17 4l0 16"></path>
</g>,
tablerIconProps,
);
-1
View File
@@ -42,7 +42,6 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
}); });
}} }}
data-testid="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
-11
View File
@@ -94,17 +94,6 @@ export const THEME = {
DARK: "dark", DARK: "dark",
}; };
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
};
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
+27 -3
View File
@@ -13,6 +13,7 @@ import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore"; import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types"; import { ImportedLibraryData } from "./types";
import UPNG from "upng-js";
const parseFileContents = async (blob: Blob | File) => { const parseFileContents = async (blob: Blob | File) => {
let contents: string; let contents: string;
@@ -210,9 +211,7 @@ export const loadLibraryFromBlob = async (
return parseLibraryJSON(await parseFileContents(blob), defaultStatus); return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
}; };
export const canvasToBlob = async ( const _canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
canvas: HTMLCanvasElement,
): Promise<Blob> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
@@ -232,6 +231,31 @@ export const canvasToBlob = async (
}); });
}; };
export const canvasToBlob = async (
canvas: HTMLCanvasElement,
): Promise<Blob> => {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No canvas context");
}
console.time("getImageData");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
console.timeEnd("getImageData");
console.time("encode");
const pngData = UPNG.encode(
[imageData.buffer],
canvas.width,
canvas.height,
0,
);
console.timeEnd("encode");
return new Blob([pngData], { type: "image/png" });
};
/** generates SHA-1 digest from supplied file (if not supported, falls back /** generates SHA-1 digest from supplied file (if not supported, falls back
to a 40-char base64 random id) */ to a 40-char base64 random id) */
export const generateIdFromFile = async (file: File): Promise<FileId> => { export const generateIdFromFile = async (file: File): Promise<FileId> => {
+2
View File
@@ -75,7 +75,9 @@ export const exportCanvas = async (
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
if (type === "png") { if (type === "png") {
console.time("export png");
let blob = await canvasToBlob(tempCanvas); let blob = await canvasToBlob(tempCanvas);
console.timeEnd("export png");
tempCanvas.remove(); tempCanvas.remove();
if (appState.exportEmbedScene) { if (appState.exportEmbedScene) {
blob = await ( blob = await (
-6
View File
@@ -62,7 +62,6 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true, freedraw: true,
eraser: false, eraser: false,
custom: true, custom: true,
frame: true,
hand: true, hand: true,
}; };
@@ -126,7 +125,6 @@ const restoreElementWithProperties = <
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness roundness: element.roundness
? element.roundness ? element.roundness
: element.strokeSharpness === "round" : element.strokeSharpness === "round"
@@ -274,10 +272,6 @@ const restoreElement = (
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "diamond": case "diamond":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
// Don't use default case so as to catch a missing an element type case. // Don't use default case so as to catch a missing an element type case.
// We also don't want to throw, but instead return void so we filter // We also don't want to throw, but instead return void so we filter
+2 -4
View File
@@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold, null) isPointHittingElementBoundingBox(element, [x, y], threshold)
) { ) {
return true; return true;
} }
@@ -440,9 +440,7 @@ export const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if ( if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element); const [x1, y1, x2] = getElementAbsoluteCoords(element);
+1 -1
View File
@@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
]; ];
export const shouldEnableBindingForPointerEvent = ( export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };
+61 -205
View File
@@ -6,7 +6,7 @@ import {
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { distance2d, rotate, rotatePoint } from "../math"; import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core"; import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types"; import { Point } from "../types";
@@ -25,101 +25,10 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
export type RectangleBox = { // x and y position of top left corner, x and y position of bottom right corner
x: number; export type Bounds = readonly [number, number, number, number];
y: number;
width: number;
height: number;
angle: number;
};
type MaybeQuadraticSolution = [number | null, number | null] | false; type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}
}
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = ( export const getElementAbsoluteCoords = (
@@ -160,111 +69,6 @@ export const getElementAbsoluteCoords = (
]; ];
}; };
/**
* for a given element, `getElementLineSegments` returns line segments
* that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection
*/
export const getElementLineSegments = (
element: ExcalidrawElement,
): [Point, Point][] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const center: Point = [cx, cy];
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
i++;
}
return segments;
}
const [nw, ne, sw, se, n, s, w, e] = (
[
[x1, y1],
[x2, y1],
[x1, y2],
[x2, y2],
[cx, y1],
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
];
};
/**
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
*
* Rectangle here means any rectangular frame, not an excalidraw element.
*/
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
return [
boxSceneCoords.x,
boxSceneCoords.y,
boxSceneCoords.x + boxSceneCoords.width,
boxSceneCoords.y + boxSceneCoords.height,
boxSceneCoords.x + boxSceneCoords.width / 2,
boxSceneCoords.y + boxSceneCoords.height / 2,
];
};
export const pointRelativeTo = ( export const pointRelativeTo = (
element: ExcalidrawElement, element: ExcalidrawElement,
absoluteCoords: Point, absoluteCoords: Point,
@@ -650,12 +454,64 @@ const getLinearElementRotatedBounds = (
return coords; return coords;
}; };
export const getElementBounds = (element: ExcalidrawElement): Bounds => { // We could cache this stuff
return ElementBounds.getBounds(element); export const getElementBounds = (
element: ExcalidrawElement,
): [number, number, number, number] => {
let bounds: [number, number, number, number];
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
),
);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
const cos = Math.cos(element.angle);
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
}
return bounds;
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Bounds => { ): [number, number, number, number] => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@@ -752,7 +608,7 @@ export const getElementPointsCoords = (
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },
): Bounds => { ): [number, number, number, number] => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@@ -773,7 +629,7 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement); return getElementBounds(closestElement);
}; };
export interface BoundingBox { export interface Box {
minX: number; minX: number;
minY: number; minY: number;
maxX: number; maxX: number;
@@ -786,7 +642,7 @@ export interface BoundingBox {
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
minX, minX,
+22 -146
View File
@@ -26,16 +26,10 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
getElementAbsoluteCoords, import { Point } from "../types";
getCurvePathOps,
getRectangleBoxAbsoluteCoords,
RectangleBox,
} from "./bounds";
import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
@@ -67,7 +61,6 @@ const isElementDraggableFromInside = (
export const hitTest = ( export const hitTest = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
@@ -79,39 +72,22 @@ export const hitTest = (
isElementSelected(appState, element) && isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState) shouldShowBoundingBox([element], appState)
) { ) {
return isPointHittingElementBoundingBox( return isPointHittingElementBoundingBox(element, point, threshold);
element,
point,
threshold,
frameNameBoundsCache,
);
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const isHittingBoundTextElement = hitTest( const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
boundTextElement,
appState,
frameNameBoundsCache,
x,
y,
);
if (isHittingBoundTextElement) { if (isHittingBoundTextElement) {
return true; return true;
} }
} }
return isHittingElementNotConsideringBoundingBox( return isHittingElementNotConsideringBoundingBox(element, appState, point);
element,
appState,
frameNameBoundsCache,
point,
);
}; };
export const isHittingElementBoundingBoxWithoutHittingElement = ( export const isHittingElementBoundingBoxWithoutHittingElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
@@ -120,33 +96,19 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box // eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if ( if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
boundTextElement &&
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
) {
return false; return false;
} }
return ( return (
!isHittingElementNotConsideringBoundingBox( !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
element, isPointHittingElementBoundingBox(element, [x, y], threshold)
appState,
frameNameBoundsCache,
[x, y],
) &&
isPointHittingElementBoundingBox(
element,
[x, y],
threshold,
frameNameBoundsCache,
)
); );
}; };
export const isHittingElementNotConsideringBoundingBox = ( export const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache | null,
point: Point, point: Point,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
@@ -155,13 +117,7 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ return hitTestPointAgainstElement({ element, point, threshold, check });
element,
point,
threshold,
check,
frameNameBoundsCache,
});
}; };
const isElementSelected = ( const isElementSelected = (
@@ -173,22 +129,7 @@ export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
[x, y]: Point, [x, y]: Point,
threshold: number, threshold: number,
frameNameBoundsCache: FrameNameBoundsCache | null,
) => { ) => {
// frames needs be checked differently so as to be able to drag it
// by its frame, whether it has been selected or not
// this logic here is not ideal
// TODO: refactor it later...
if (element.type === "frame") {
return hitTestPointAgainstElement({
element,
point: [x, y],
threshold,
check: isInsideCheck,
frameNameBoundsCache,
});
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCenterX = (x1 + x2) / 2; const elementCenterX = (x1 + x2) / 2;
const elementCenterY = (y1 + y2) / 2; const elementCenterY = (y1 + y2) / 2;
@@ -216,13 +157,7 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck; const check = isOutsideCheck;
const point: Point = [x, y]; const point: Point = [x, y];
return hitTestPointAgainstElement({ return hitTestPointAgainstElement({ element, point, threshold, check });
element,
point,
threshold,
check,
frameNameBoundsCache: null,
});
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@@ -242,7 +177,6 @@ type HitTestArgs = {
point: Point; point: Point;
threshold: number; threshold: number;
check: (distance: number, threshold: number) => boolean; check: (distance: number, threshold: number) => boolean;
frameNameBoundsCache: FrameNameBoundsCache | null;
}; };
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
@@ -274,27 +208,6 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.", "This should not happen, we need to investigate why it does.",
); );
return false; return false;
case "frame": {
// check distance to frame element first
if (
args.check(
distanceToBindableElement(args.element, args.point),
args.threshold,
)
) {
return true;
}
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
if (frameNameBounds) {
return args.check(
distanceToRectangleBox(frameNameBounds, args.point),
args.threshold,
);
}
return false;
}
} }
}; };
@@ -306,7 +219,6 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@@ -336,8 +248,7 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement,
| ExcalidrawFrameElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -347,14 +258,6 @@ const distanceToRectangle = (
); );
}; };
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
return Math.max(
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
);
};
const distanceToDiamond = ( const distanceToDiamond = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
point: Point, point: Point,
@@ -554,7 +457,8 @@ const pointRelativeToElement = (
): [GA.Point, GA.Point, number, number] => { ): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple); const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2); const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point); const pointRotated = GATransform.apply(rotate, point);
@@ -562,26 +466,9 @@ const pointRelativeToElement = (
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y); const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos); const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2; const [ax, ay, bx, by] = elementCoords;
const halfHeight = (y2 - y1) / 2; const halfWidth = (bx - ax) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; const halfHeight = (by - ay) / 2;
};
const pointRelativeToDivElement = (
pointTuple: Point,
rectangle: RectangleBox,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
const center = coordsCenter(x1, y1, x2, y2);
const rotate = GATransform.rotation(center, rectangle.angle);
const pointRotated = GATransform.apply(rotate, point);
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(rectangle.x, rectangle.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
}; };
@@ -603,7 +490,7 @@ const relativizationToElementCenter = (
element: ExcalidrawElement, element: ExcalidrawElement,
): GA.Transform => { ): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2); const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse( const translate = GA.reverse(
@@ -612,13 +499,8 @@ const relativizationToElementCenter = (
return GATransform.compose(rotate, translate); return GATransform.compose(rotate, translate);
}; };
const coordsCenter = ( const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
x1: number, return GA.point((ax + bx) / 2, (ay + by) / 2);
y1: number,
x2: number,
y2: number,
): GA.Point => {
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
}; };
// The focus distance is the oriented ratio between the size of // The focus distance is the oriented ratio between the size of
@@ -649,7 +531,6 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return c / (hwidth * (nabs + q * mabs)); return c / (hwidth * (nabs + q * mabs));
case "diamond": case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@@ -667,7 +548,7 @@ export const determineFocusPoint = (
): Point => { ): Point => {
if (focus === 0) { if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(x1, y1, x2, y2); const center = coordsCenter([x1, y1, x2, y2]);
return GAPoint.toTuple(center); return GAPoint.toTuple(center);
} }
const relateToCenter = relativizationToElementCenter(element); const relateToCenter = relativizationToElementCenter(element);
@@ -682,7 +563,6 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@@ -733,7 +613,6 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@@ -767,8 +646,7 @@ const getCorners = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement,
| ExcalidrawFrameElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@@ -777,7 +655,6 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@@ -925,8 +802,7 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement,
| ExcalidrawFrameElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,
+3 -32
View File
@@ -6,8 +6,6 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -18,31 +16,10 @@ export const dragSelectedElements = (
distanceX: number = 0, distanceX: number = 0,
distanceY: number = 0, distanceY: number = 0,
appState: AppState, appState: AppState,
scene: Scene,
) => { ) => {
const [x1, y1] = getCommonBounds(selectedElements); const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 }; const offset = { x: pointerX - x1, y: pointerY - y1 };
selectedElements.forEach((element) => {
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameElement(e))
.map((f) => f.id);
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
elementsToUpdate.forEach((element) => {
updateElementCoords( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@@ -61,13 +38,7 @@ export const dragSelectedElements = (
(appState.editingGroupId && !isSelectedViaGroup(appState, element)) (appState.editingGroupId && !isSelectedViaGroup(appState, element))
) { ) {
const textElement = getBoundTextElement(element); const textElement = getBoundTextElement(element);
if ( if (textElement) {
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@@ -79,7 +50,7 @@ export const dragSelectedElements = (
} }
} }
updateBoundElements(element, { updateBoundElements(element, {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: selectedElements,
}); });
}); });
}; };
+1 -13
View File
@@ -2,7 +2,6 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@@ -50,11 +49,7 @@ export {
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { export { isTextElement, isExcalidrawElement } from "./typeChecks";
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
@@ -79,13 +74,6 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
(element) => !element.isDeleted, (element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[]; ) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) =>
frames.filter(
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>( export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,
): element is NonDeleted<T> => !element.isDeleted; ): element is NonDeleted<T> => !element.isDeleted;
+1 -1
View File
@@ -594,7 +594,7 @@ export class LinearElementEditor {
} }
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLCanvasElement>,
appState: AppState, appState: AppState,
history: History, history: History,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
-25
View File
@@ -12,7 +12,6 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import {
arrayToMap, arrayToMap,
@@ -51,7 +50,6 @@ type ElementConstructorOpts = MarkOptional<
| "height" | "height"
| "angle" | "angle"
| "groupIds" | "groupIds"
| "frameId"
| "boundElements" | "boundElements"
| "seed" | "seed"
| "version" | "version"
@@ -84,7 +82,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0, height = 0,
angle = 0, angle = 0,
groupIds = [], groupIds = [],
frameId = null,
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
@@ -109,7 +106,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness, roughness,
opacity, opacity,
groupIds, groupIds,
frameId,
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
@@ -130,21 +126,6 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
export const newFrameElement = (
opts: ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame",
name: null,
},
{},
);
return frameElement;
};
/** computes element x/y offset based on textAlign/verticalAlign */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {
@@ -177,7 +158,6 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"];
isFrameName?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@@ -212,7 +192,6 @@ export const newTextElement = (
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight, lineHeight,
isFrameName: opts.isFrameName || false,
}, },
{}, {},
); );
@@ -633,10 +612,6 @@ export const duplicateElements = (
: null; : null;
} }
if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
}
clonedElements.push(clonedElement); clonedElements.push(clonedElement);
} }
+37 -45
View File
@@ -27,7 +27,6 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -161,17 +160,12 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle: number; let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (isFrameElement(element)) { if (shouldRotateWithDiscreteAngle) {
angle = 0; angle += SHIFT_LOCKING_ANGLE / 2;
} else { angle -= angle % SHIFT_LOCKING_ANGLE;
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
angle = normalizeAngle(angle);
} }
angle = normalizeAngle(angle);
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle }); mutateElement(element, { angle });
@@ -883,47 +877,45 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2; centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
} }
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const origAngle =
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate(
cx,
cy,
centerX,
centerY,
centerAngle + origAngle - element.angle,
);
elements mutateElement(
.filter((element) => element.type !== "frame") element,
.forEach((element) => { {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); x: element.x + (rotatedCX - cx),
const cx = (x1 + x2) / 2; y: element.y + (rotatedCY - cy),
const cy = (y1 + y2) / 2; angle: normalizeAngle(centerAngle + origAngle),
const origAngle = },
pointerDownState.originalElements.get(element.id)?.angle ?? false,
element.angle; );
const [rotatedCX, rotatedCY] = rotate(
cx, updateBoundElements(element, { simultaneouslyUpdated: elements });
cy,
centerX, const boundText = getBoundTextElement(element);
centerY, if (boundText && !isArrowElement(element)) {
centerAngle + origAngle - element.angle,
);
mutateElement( mutateElement(
element, boundText,
{ {
x: element.x + (rotatedCX - cx), x: boundText.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy), y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle), angle: normalizeAngle(centerAngle + origAngle),
}, },
false, false,
); );
updateBoundElements(element, { simultaneouslyUpdated: elements }); }
});
const boundText = getBoundTextElement(element);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
}
});
Scene.getScene(elements[0])?.informMutation(); Scene.getScene(elements[0])?.informMutation();
}; };
+4 -6
View File
@@ -840,12 +840,10 @@ export const getTextBindableContainerAtPosition = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if ( if (
isArrowElement(elements[index]) && isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox( isHittingElementNotConsideringBoundingBox(elements[index], appState, [
elements[index], x,
appState, y,
null, ])
[x, y],
)
) { ) {
hitElement = elements[index]; hitElement = elements[index];
break; break;
+1 -13
View File
@@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isFrameElement, isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene"; import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
@@ -44,14 +44,6 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
w: true, w: true,
}; };
export const OMIT_SIDES_FOR_FRAME = {
e: true,
s: true,
n: true,
w: true,
rotation: true,
};
const OMIT_SIDES_FOR_TEXT_ELEMENT = { const OMIT_SIDES_FOR_TEXT_ELEMENT = {
e: true, e: true,
s: true, s: true,
@@ -257,10 +249,6 @@ export const getTransformHandles = (
} }
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) {
omitSides = {
rotation: true,
};
} }
const dashedLineMargin = isLinearElement(element) const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING + 8 ? DEFAULT_SPACING + 8
-7
View File
@@ -12,7 +12,6 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
RoundnessType, RoundnessType,
} from "./types"; } from "./types";
@@ -46,12 +45,6 @@ export const isTextElement = (
return element != null && element.type === "text"; return element != null && element.type === "text";
}; };
export const isFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameElement => {
return element != null && element.type === "frame";
};
export const isFreeDrawElement = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {
+2 -10
View File
@@ -53,7 +53,6 @@ type _ExcalidrawElementBase = Readonly<{
/** List of groups the element belongs to. /** List of groups the element belongs to.
Ordered from deepest to shallowest. */ Ordered from deepest to shallowest. */
groupIds: readonly GroupId[]; groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */ /** other elements that are bound to this element */
boundElements: boundElements:
| readonly Readonly<{ | readonly Readonly<{
@@ -99,11 +98,6 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
"fileId" "fileId"
>; >;
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
type: "frame";
name: string | null;
};
/** /**
* These are elements that don't have any additional properties. * These are elements that don't have any additional properties.
*/ */
@@ -123,8 +117,7 @@ export type ExcalidrawElement =
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement;
| ExcalidrawFrameElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean; isDeleted: boolean;
@@ -155,8 +148,7 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement | ExcalidrawImageElement;
| ExcalidrawFrameElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
+1 -9
View File
@@ -157,8 +157,6 @@ class Collab extends PureComponent<Props, CollabState> {
window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle);
window.addEventListener(EVENT.UNLOAD, this.onUnload); window.addEventListener(EVENT.UNLOAD, this.onUnload);
this.onOfflineStatusToggle();
const collabAPI: CollabAPI = { const collabAPI: CollabAPI = {
isCollaborating: this.isCollaborating, isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate, onPointerUpdate: this.onPointerUpdate,
@@ -170,6 +168,7 @@ class Collab extends PureComponent<Props, CollabState> {
}; };
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle();
if ( if (
process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.TEST ||
@@ -381,13 +380,6 @@ class Collab extends PureComponent<Props, CollabState> {
startCollaboration = async ( startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string }, existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => { ): Promise<ImportedDataState | null> => {
if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername();
this.onUsernameChange(username);
});
}
if (this.portal.socket) { if (this.portal.socket) {
return null; return null;
} }
@@ -6,7 +6,6 @@ import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
<MainMenu> <MainMenu>
@@ -14,12 +13,10 @@ export const AppMainMenu: React.FC<{
<MainMenu.DefaultItems.SaveToActiveFile /> <MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
{props.isCollabEnabled && ( <MainMenu.DefaultItems.LiveCollaborationTrigger
<MainMenu.DefaultItems.LiveCollaborationTrigger isCollaborating={props.isCollaborating}
isCollaborating={props.isCollaborating} onSelect={() => props.setCollabDialogShown(true)}
onSelect={() => props.setCollabDialogShown(true)} />
/>
)}
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
@@ -6,7 +6,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
isCollabEnabled: boolean;
}> = React.memo((props) => { }> = React.memo((props) => {
const { t } = useI18n(); const { t } = useI18n();
let headingContent; let headingContent;
@@ -47,11 +46,9 @@ export const AppWelcomeScreen: React.FC<{
<WelcomeScreen.Center.Menu> <WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene /> <WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp /> <WelcomeScreen.Center.MenuItemHelp />
{props.isCollabEnabled && ( <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger onSelect={() => props.setCollabDialogShown(true)}
onSelect={() => props.setCollabDialogShown(true)} />
/>
)}
{!isExcalidrawPlusSignedUser && ( {!isExcalidrawPlusSignedUser && (
<WelcomeScreen.Center.MenuItemLink <WelcomeScreen.Center.MenuItemLink
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
+11 -22
View File
@@ -42,7 +42,6 @@ import {
preventUnload, preventUnload,
ResolvablePromise, ResolvablePromise,
resolvablePromise, resolvablePromise,
isRunningInIframe,
} from "../utils"; } from "../utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
@@ -99,7 +98,7 @@ languageDetector.init({
}); });
const initializeScene = async (opts: { const initializeScene = async (opts: {
collabAPI: CollabAPI | null; collabAPI: CollabAPI;
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
}): Promise< }): Promise<
{ scene: ExcalidrawInitialDataState | null } & ( { scene: ExcalidrawInitialDataState | null } & (
@@ -184,7 +183,7 @@ const initializeScene = async (opts: {
} }
} }
if (roomLinkData && opts.collabAPI) { if (roomLinkData) {
const { excalidrawAPI } = opts; const { excalidrawAPI } = opts;
const scene = await opts.collabAPI.startCollaboration(roomLinkData); const scene = await opts.collabAPI.startCollaboration(roomLinkData);
@@ -238,7 +237,6 @@ export const appLangCodeAtom = atom(
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom); const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -274,7 +272,7 @@ const ExcalidrawWrapper = () => {
}); });
useEffect(() => { useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { if (!collabAPI || !excalidrawAPI) {
return; return;
} }
@@ -285,7 +283,7 @@ const ExcalidrawWrapper = () => {
if (!data.scene) { if (!data.scene) {
return; return;
} }
if (collabAPI?.isCollaborating()) { if (collabAPI.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
.fetchImageFilesFromFirebase({ .fetchImageFilesFromFirebase({
@@ -355,7 +353,7 @@ const ExcalidrawWrapper = () => {
const libraryUrlTokens = parseLibraryTokensFromUrl(); const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) { if (!libraryUrlTokens) {
if ( if (
collabAPI?.isCollaborating() && collabAPI.isCollaborating() &&
!isCollaborationLink(window.location.href) !isCollaborationLink(window.location.href)
) { ) {
collabAPI.stopCollaboration(false); collabAPI.stopCollaboration(false);
@@ -384,10 +382,7 @@ const ExcalidrawWrapper = () => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
} }
if ( if (!document.hidden && !collabAPI.isCollaborating()) {
!document.hidden &&
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
) {
// don't sync if local state is newer or identical to browser state // don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
@@ -403,7 +398,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateLibrary({ excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(), libraryItems: getLibraryItemsFromStorage(),
}); });
collabAPI?.setUsername(username || ""); collabAPI.setUsername(username || "");
} }
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
@@ -471,7 +466,7 @@ const ExcalidrawWrapper = () => {
); );
clearTimeout(titleTimeout); clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -654,7 +649,7 @@ const ExcalidrawWrapper = () => {
autoFocus={true} autoFocus={true}
theme={theme} theme={theme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile) {
return null; return null;
} }
return ( return (
@@ -668,21 +663,15 @@ const ExcalidrawWrapper = () => {
<AppMainMenu <AppMainMenu
setCollabDialogShown={setCollabDialogShown} setCollabDialogShown={setCollabDialogShown}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
/>
<AppWelcomeScreen
setCollabDialogShown={setCollabDialogShown}
isCollabEnabled={!isCollabDisabled}
/> />
<AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
<AppFooter /> <AppFooter />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </div>
)} )}
{excalidrawAPI && !isCollabDisabled && ( {excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
<Collab excalidrawAPI={excalidrawAPI} />
)}
</Excalidraw> </Excalidraw>
{errorMessage && ( {errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}> <ErrorDialog onClose={() => setErrorMessage("")}>
-705
View File
@@ -1,705 +0,0 @@
import {
getCommonBounds,
getElementAbsoluteCoords,
isTextElement,
} from "./element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { isPointWithinBounds } from "./math";
import {
getBoundTextElement,
getContainerElement,
} from "./element/textElement";
import { arrayToMap, findIndex } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
nextElements: ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
const nextElementMap = arrayToMap(nextElements) as Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
for (const element of oldElements) {
if (element.frameId) {
// use its frameId to get the new frameId
const nextElementId = oldIdToDuplicatedId.get(element.id);
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
if (nextElementId) {
const nextElement = nextElementMap.get(nextElementId);
if (nextElement) {
mutateElement(
nextElement,
{
frameId: nextFrameId ?? element.frameId,
},
false,
);
}
}
}
}
};
// --------------------------- Frame Geometry ---------------------------------
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
class LineSegment {
first: Point;
second: Point;
constructor(pointA: Point, pointB: Point) {
this.first = pointA;
this.second = pointB;
}
public getBoundingBox(): [Point, Point] {
return [
new Point(
Math.min(this.first.x, this.second.x),
Math.min(this.first.y, this.second.y),
),
new Point(
Math.max(this.first.x, this.second.x),
Math.max(this.first.y, this.second.y),
),
];
}
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
class FrameGeometry {
private static EPSILON = 0.000001;
private static crossProduct(a: Point, b: Point) {
return a.x * b.y - b.x * a.y;
}
private static doBoundingBoxesIntersect(
a: [Point, Point],
b: [Point, Point],
) {
return (
a[0].x <= b[1].x &&
a[1].x >= b[0].x &&
a[0].y <= b[1].y &&
a[1].y >= b[0].y
);
}
private static isPointOnLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
const r = this.crossProduct(aTmp.second, bTmp);
return Math.abs(r) < this.EPSILON;
}
private static isPointRightOfLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
return this.crossProduct(aTmp.second, bTmp) < 0;
}
private static lineSegmentTouchesOrCrossesLine(
a: LineSegment,
b: LineSegment,
) {
return (
this.isPointOnLine(a, b.first) ||
this.isPointOnLine(a, b.second) ||
(this.isPointRightOfLine(a, b.first)
? !this.isPointRightOfLine(a, b.second)
: this.isPointRightOfLine(a, b.second))
);
}
private static doLineSegmentsIntersect(
a: [readonly [number, number], readonly [number, number]],
b: [readonly [number, number], readonly [number, number]],
) {
const aSegment = new LineSegment(
new Point(a[0][0], a[0][1]),
new Point(a[1][0], a[1][1]),
);
const bSegment = new LineSegment(
new Point(b[0][0], b[0][1]),
new Point(b[1][0], b[1][1]),
);
const box1 = aSegment.getBoundingBox();
const box2 = bSegment.getBoundingBox();
return (
this.doBoundingBoxesIntersect(box1, box2) &&
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
);
}
public static isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
const elementLineSegments = getElementLineSegments(element);
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
return intersecting;
}
}
export const getElementsCompletelyInFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) =>
omitGroupsContainingFrames(
getElementsWithinSelection(elements, frame, false),
).filter(
(element) =>
(element.type !== "frame" && !element.frameId) ||
element.frameId === frame.id,
);
export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) => {
return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id,
);
};
export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) =>
elements.filter((element) =>
FrameGeometry.isElementIntersectingFrame(element, frame),
);
export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame);
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
return (
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
};
export const elementOverlapsWithFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) => {
return (
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame) ||
isElementContainingFrame([frame], element, frame)
);
};
export const isCursorInFrame = (
cursorCoords: {
x: number;
y: number;
},
frame: NonDeleted<ExcalidrawFrameElement>,
) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
return isPointWithinBounds(
[fx1, fy1],
[cursorCoords.x, cursorCoords.y],
[fx2, fy2],
);
};
export const groupsAreAtLeastIntersectingTheFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
);
if (elementsInGroup.length === 0) {
return true;
}
return !!elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
);
};
export const groupsAreCompletelyOutOfFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
);
if (elementsInGroup.length === 0) {
return true;
}
return (
elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
) === undefined
);
};
// --------------------------- Frame Utils ------------------------------------
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
>();
for (const element of elements) {
const frameId = isFrameElement(element) ? element.id : element.frameId;
if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
}
}
return frameElementsMap;
};
export const getFrameElements = (
allElements: ExcalidrawElementsIncludingDeleted,
frameId: string,
) => allElements.filter((element) => element.frameId === frameId);
export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
appState: AppState,
): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameElements(allElements, frame.id);
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame),
...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame),
),
]);
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
(element) => !elementsCompletelyInFrame.has(element),
);
// for elements that are completely in the frame
// if they are part of some groups, then those groups are still
// considered to belong to the frame
const groupsToKeep = new Set<string>(
Array.from(elementsCompletelyInFrame).flatMap(
(element) => element.groupIds,
),
);
for (const element of elementsNotCompletelyInFrame) {
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
if (element.groupIds.length === 0) {
nextElementsInFrame.delete(element);
}
} else if (element.groupIds.length > 0) {
// group element intersects with the frame, we should keep the groups
// that this element is part of
for (const id of element.groupIds) {
groupsToKeep.add(id);
}
}
}
for (const element of elementsNotCompletelyInFrame) {
if (element.groupIds.length > 0) {
let shouldRemoveElement = true;
for (const id of element.groupIds) {
if (groupsToKeep.has(id)) {
shouldRemoveElement = false;
}
}
if (shouldRemoveElement) {
nextElementsInFrame.delete(element);
}
}
}
const individualElementsCompletelyInFrame = Array.from(
elementsCompletelyInFrame,
).filter((element) => element.groupIds.length === 0);
for (const element of individualElementsCompletelyInFrame) {
nextElementsInFrame.add(element);
}
const newGroupElementsCompletelyInFrame = Array.from(
elementsCompletelyInFrame,
).filter((element) => element.groupIds.length > 0);
const groupIds = selectGroupsFromGivenElements(
newGroupElementsCompletelyInFrame,
appState,
);
// new group elements
for (const [id, isSelected] of Object.entries(groupIds)) {
if (isSelected) {
const elementsInGroup = getElementsInGroup(allElements, id);
if (elementsAreInFrameBounds(elementsInGroup, frame)) {
for (const element of elementsInGroup) {
nextElementsInFrame.add(element);
}
}
}
}
return [...nextElementsInFrame].filter((element) => {
return !(isTextElement(element) && element.containerId);
});
};
export const getElementsInNewFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
) => {
return omitGroupsContainingFrames(
allElements,
getElementsCompletelyInFrame(allElements, frame),
);
};
export const getContainingFrame = (
element: ExcalidrawElement,
/**
* Optionally an elements map, in case the elements aren't in the Scene yet.
* Takes precedence over Scene elements, even if the element exists
* in Scene elements and not the supplied elements map.
*/
elementsMap?: Map<string, ExcalidrawElement>,
) => {
if (element.frameId) {
if (elementsMap) {
return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameElement;
}
return (
(Scene.getScene(element)?.getElement(
element.frameId,
) as ExcalidrawFrameElement) || null
);
}
return null;
};
// --------------------------- Frame Operations -------------------------------
export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const _elementsToAdd: ExcalidrawElement[] = [];
for (const element of elementsToAdd) {
_elementsToAdd.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToAdd.push(boundTextElement);
}
}
let nextElements = allElements.slice();
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
)) {
if (element.frameId !== frame.id && !isFrameElement(element)) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
if (elementIndex < frameBoundary) {
nextElements = [
...nextElements.slice(0, elementIndex),
...nextElements.slice(elementIndex + 1, frameBoundary),
element,
...nextElements.slice(frameBoundary),
];
} else if (elementIndex > frameIndex) {
nextElements = [
...nextElements.slice(0, frameIndex),
element,
...nextElements.slice(frameIndex, elementIndex),
...nextElements.slice(elementIndex + 1),
];
}
}
}
return nextElements;
};
export const removeElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const _elementsToRemove: ExcalidrawElement[] = [];
for (const element of elementsToRemove) {
if (element.frameId) {
_elementsToRemove.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToRemove.push(boundTextElement);
}
}
}
for (const element of _elementsToRemove) {
mutateElement(
element,
{
frameId: null,
},
false,
);
}
const nextElements = moveOneRight(
allElements,
appState,
Array.from(_elementsToRemove),
);
return nextElements;
};
export const removeAllElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
const elementsInFrame = getFrameElements(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState);
};
export const replaceAllElementsInFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameElement,
appState: AppState,
) => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame, appState),
nextElementsInFrame,
frame,
);
};
/** does not mutate elements, but return new ones */
export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
) => {
const selectedElements = getSelectedElements(allElements, appState);
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
if (appState.editingGroupId) {
for (const element of selectedElements) {
if (element.groupIds.length === 0) {
elementsToFilter.add(element);
} else {
element.groupIds
.flatMap((gid) => getElementsInGroup(allElements, gid))
.forEach((element) => elementsToFilter.add(element));
}
}
}
const elementsToRemove = new Set<ExcalidrawElement>();
elementsToFilter.forEach((element) => {
if (
!isFrameElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
elementsToRemove.add(element);
}
});
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
};
/**
* filters out elements that are inside groups that contain a frame element
* anywhere in the group tree
*/
export const omitGroupsContainingFrames = (
allElements: ExcalidrawElementsIncludingDeleted,
/** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily
*/
selectedElements?: readonly ExcalidrawElement[],
) => {
const uniqueGroupIds = new Set<string>();
for (const el of selectedElements || allElements) {
const topMostGroupId = el.groupIds[el.groupIds.length - 1];
if (topMostGroupId) {
uniqueGroupIds.add(topMostGroupId);
}
}
const rejectedGroupIds = new Set<string>();
for (const groupId of uniqueGroupIds) {
if (
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
) {
rejectedGroupIds.add(groupId);
}
}
return (selectedElements || allElements).filter(
(el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
);
};
/**
* depending on the appState, return target frame, which is the frame the given element
* is going to be added to or remove from
*/
export const getTargetFrame = (
element: ExcalidrawElement,
appState: AppState,
) => {
const _element = isTextElement(element)
? getContainerElement(element) || element
: element;
return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged
? appState.frameToHighlight
: getContainingFrame(_element);
};
// given an element, return if the element is in some frame
export const isElementInFrame = (
element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,
) => {
const frame = getTargetFrame(element, appState);
const _element = isTextElement(element)
? getContainerElement(element) || element
: element;
if (frame) {
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame);
}
const allElementsInGroup = new Set(
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElements, appState),
);
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
if (editingGroupOverlapsFrame) {
return true;
}
selectedElements.forEach((selectedElement) => {
allElementsInGroup.delete(selectedElement);
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) {
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame)) {
return true;
}
}
}
return false;
};
-40
View File
@@ -94,31 +94,6 @@ export const selectGroupsForSelectedElements = (
return nextAppState; return nextAppState;
}; };
// given a list of elements, return the the actual group ids that should be selected
// or used to update the elements
export const selectGroupsFromGivenElements = (
elements: readonly NonDeleted<ExcalidrawElement>[],
appState: AppState,
) => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
for (const element of elements) {
let groupIds = element.groupIds;
if (appState.editingGroupId) {
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1];
nextAppState = selectGroup(groupId, nextAppState, elements);
}
}
return nextAppState.selectedGroupIds;
};
export const editGroupForSelectedElement = ( export const editGroupForSelectedElement = (
appState: AppState, appState: AppState,
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
@@ -211,18 +186,3 @@ export const getMaximumGroups = (
return Array.from(groups.values()); return Array.from(groups.values());
}; };
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();
let maxGroup = 0;
for (const group of allGroups) {
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
if (groupCount.get(group)! > maxGroup) {
maxGroup = groupCount.get(group)!;
}
}
return maxGroup === elements.length;
};
-1
View File
@@ -42,7 +42,6 @@ export const KEYS = {
CHEVRON_RIGHT: ">", CHEVRON_RIGHT: ">",
PERIOD: ".", PERIOD: ".",
COMMA: ",", COMMA: ",",
SUBTRACT: "-",
A: "a", A: "a",
C: "c", C: "c",
-449
View File
@@ -1,449 +0,0 @@
{
"labels": {
"paste": "Yapışdır",
"pasteAsPlaintext": "Düz mətn kimi yapışdırın",
"pasteCharts": "Diaqramları yapışdırın",
"selectAll": "Hamısını seç",
"multiSelect": "Seçimə element əlavə edin",
"moveCanvas": "Kanvası köçürün",
"cut": "Kəs",
"copy": "Kopyala",
"copyAsPng": "PNG olaraq panoya kopyala",
"copyAsSvg": "SVG olaraq panoya kopyala",
"copyText": "Mətn olaraq panoya kopyala",
"bringForward": "Önə daşı",
"sendToBack": "Geriyə göndərin",
"bringToFront": "Önə gətirin",
"sendBackward": "Geriyə göndərin",
"delete": "Sil",
"copyStyles": "Stilləri kopyalayın",
"pasteStyles": "Stilləri yapışdırın",
"stroke": "Strok rəngi",
"background": "Arxa fon",
"fill": "Doldur",
"strokeWidth": "Strok eni",
"strokeStyle": "Strok stili",
"strokeStyle_solid": "Solid",
"strokeStyle_dashed": "Kəsik",
"strokeStyle_dotted": "Nöqtəli",
"sloppiness": "Səliqəsizlik",
"opacity": "Şəffaflıq",
"textAlign": "Mətni uyğunlaşdır",
"edges": "Kənarlar",
"sharp": "Kəskin",
"round": "Dəyirmi",
"arrowheads": "Ox ucları",
"arrowhead_none": "Heç biri",
"arrowhead_arrow": "Ox",
"arrowhead_bar": "Çubuq",
"arrowhead_dot": "Nöqtə",
"arrowhead_triangle": "Üçbucaq",
"fontSize": "Şrift ölçüsü",
"fontFamily": "Şrift qrupu",
"addWatermark": "\"Made with Excalidraw\" əlavə et",
"handDrawn": "Əllə çəkilmiş",
"normal": "Normal",
"code": "Kod",
"small": "Kiçik",
"medium": "Orta",
"large": "Böyük",
"veryLarge": "Çox böyük",
"solid": "Solid",
"hachure": "Ştrix",
"zigzag": "Ziqzaq",
"crossHatch": "Çarpaz dəlik",
"thin": "İncə",
"bold": "Qalın",
"left": "Sol",
"center": "Mərkəz",
"right": "Sağ",
"extraBold": "Ekstra qalın",
"architect": "Memar",
"artist": "Rəssam",
"cartoonist": "Karikaturaçı",
"fileTitle": "Fayl adı",
"colorPicker": "Rəng seçən",
"canvasColors": "Kanvas üzərində istifadə olunur",
"canvasBackground": "Kanvas arxa fonu",
"drawingCanvas": "Kanvas çəkmək",
"layers": "Qatlar",
"actions": "Hərəkətlər",
"language": "Dil",
"liveCollaboration": "Canlı əməkdaşlıq...",
"duplicateSelection": "Dublikat",
"untitled": "Başlıqsız",
"name": "Ad",
"yourName": "Adınız",
"madeWithExcalidraw": "Excalidraw ilə hazırlanmışdır",
"group": "Qrup şəklində seçim",
"ungroup": "Qrupsuz seçim",
"collaborators": "",
"showGrid": "",
"addToLibrary": "",
"removeFromLibrary": "",
"libraryLoadingMessage": "",
"libraries": "",
"loadingScene": "",
"align": "",
"alignTop": "",
"alignBottom": "",
"alignLeft": "",
"alignRight": "",
"centerVertically": "",
"centerHorizontally": "",
"distributeHorizontally": "",
"distributeVertically": "",
"flipHorizontal": "",
"flipVertical": "",
"viewMode": "",
"share": "",
"showStroke": "",
"showBackground": "",
"toggleTheme": "",
"personalLib": "",
"excalidrawLib": "",
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"createContainerFromText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"lineEditor": {
"edit": "",
"exit": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
},
"statusPublished": "",
"sidebarLock": "",
"eyeDropper": ""
},
"library": {
"noItems": "",
"hint_emptyLibrary": "",
"hint_emptyPrivateLibrary": ""
},
"buttons": {
"clearReset": "",
"exportJSON": "",
"exportImage": "",
"export": "",
"copyToClipboard": "",
"save": "",
"saveAs": "",
"load": "",
"getShareableLink": "",
"close": "",
"selectLanguage": "",
"scrollBackToContent": "",
"zoomIn": "",
"zoomOut": "",
"resetZoom": "",
"menu": "",
"done": "",
"edit": "",
"undo": "",
"redo": "",
"resetLibrary": "",
"createNewRoom": "",
"fullScreen": "",
"darkMode": "",
"lightMode": "",
"zenMode": "",
"exitZenMode": "",
"cancel": "",
"clear": "",
"remove": "",
"publishLibrary": "",
"submit": "",
"confirm": ""
},
"alerts": {
"clearReset": "",
"couldNotCreateShareableLink": "",
"couldNotCreateShareableLinkTooBig": "",
"couldNotLoadInvalidFile": "",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
"collabStopOverridePrompt": "",
"errorAddingToLibrary": "",
"errorRemovingFromLibrary": "",
"confirmAddLibrary": "",
"imageDoesNotContainScene": "",
"cannotRestoreFromImage": "",
"invalidSceneUrl": "",
"resetLibrary": "",
"removeItemsFromsLibrary": "",
"invalidEncryptionKey": "",
"collabOfflineWarning": ""
},
"errors": {
"unsupportedFileType": "",
"imageInsertError": "",
"fileTooBig": "",
"svgImageInsertError": "",
"invalidSVGString": "",
"cannotResolveCollabServer": "",
"importLibraryError": "",
"collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": {
"line1": "",
"line2": "",
"line3": "",
"line4": ""
}
},
"toolBar": {
"selection": "",
"image": "",
"rectangle": "",
"diamond": "",
"ellipse": "",
"arrow": "",
"line": "",
"freedraw": "",
"text": "",
"library": "",
"lock": "",
"penMode": "",
"link": "",
"eraser": "",
"hand": ""
},
"headings": {
"canvasActions": "",
"selectedShapeActions": "",
"shapes": ""
},
"hints": {
"canvasPanning": "",
"linearElement": "",
"freeDraw": "",
"text": "",
"text_selected": "",
"text_editing": "",
"linearElementMulti": "",
"lockAngle": "",
"resize": "",
"resizeImage": "",
"rotate": "",
"lineEditor_info": "",
"lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "",
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": "",
"eraserRevert": "",
"firefox_clipboard_write": ""
},
"canvasError": {
"cannotShowPreview": "",
"canvasTooBig": "",
"canvasTooBigTip": ""
},
"errorSplash": {
"headingMain": "",
"clearCanvasMessage": "",
"clearCanvasCaveat": "",
"trackedToSentry": "",
"openIssueMessage": "",
"sceneContent": ""
},
"roomDialog": {
"desc_intro": "",
"desc_privacy": "",
"button_startSession": "",
"button_stopSession": "",
"desc_inProgressIntro": "",
"desc_shareLink": "",
"desc_exitSession": "",
"shareTitle": ""
},
"errorDialog": {
"title": ""
},
"exportDialog": {
"disk_title": "",
"disk_details": "",
"disk_button": "",
"link_title": "",
"link_details": "",
"link_button": "",
"excalidrawplus_description": "",
"excalidrawplus_button": "",
"excalidrawplus_exportError": ""
},
"helpDialog": {
"blog": "",
"click": "",
"deepSelect": "",
"deepBoxSelect": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"doubleClick": "",
"drag": "",
"editor": "",
"editLineArrowPoints": "",
"editText": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"tools": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": "",
"toggleElementLock": "",
"movePageUpDown": "",
"movePageLeftRight": ""
},
"clearCanvasDialog": {
"title": ""
},
"publishDialog": {
"title": "",
"itemName": "",
"authorName": "",
"githubUsername": "",
"twitterUsername": "",
"libraryName": "",
"libraryDesc": "",
"website": "",
"placeholder": {
"authorName": "",
"libraryName": "",
"libraryDesc": "",
"githubHandle": "",
"twitterHandle": "",
"website": ""
},
"errors": {
"required": "",
"website": ""
},
"noteDescription": "",
"noteGuidelines": "",
"noteLicense": "",
"noteItems": "",
"atleastOneLibItem": "",
"republishWarning": ""
},
"publishSuccessDialog": {
"title": "",
"content": ""
},
"confirmDialog": {
"resetLibrary": "",
"removeItemsFromLib": ""
},
"imageExportDialog": {
"header": "",
"label": {
"withBackground": "",
"onlySelected": "",
"darkMode": "",
"embedScene": "",
"scale": "",
"padding": ""
},
"tooltip": {
"embedScene": ""
},
"title": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
},
"button": {
"exportToPng": "",
"exportToSvg": "",
"copyPngToClipboard": ""
}
},
"encrypted": {
"tooltip": "",
"link": ""
},
"stats": {
"angle": "",
"element": "",
"elements": "",
"height": "",
"scene": "",
"selected": "",
"storage": "",
"title": "",
"total": "",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": ""
},
"toast": {
"addedToLibrary": "",
"copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": "",
"canvas": "",
"selection": "",
"pasteAsSingleElement": ""
},
"colors": {
"transparent": "",
"black": "",
"white": "",
"red": "",
"pink": "",
"grape": "",
"violet": "",
"gray": "",
"blue": "",
"cyan": "",
"teal": "",
"green": "",
"yellow": "",
"orange": "",
"bronze": ""
},
"welcomeScreen": {
"app": {
"center_heading": "",
"center_heading_plus": "",
"menuHint": ""
},
"defaults": {
"menuHint": "",
"center_heading": "",
"toolbarHint": "",
"helpHint": ""
}
},
"colorPicker": {
"mostUsedCustomColors": "",
"colors": "",
"shades": "",
"hexCode": "",
"noShades": ""
}
}
+133 -133
View File
@@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Vložit", "paste": "Vložit",
"pasteAsPlaintext": "Vložit jako prostý text", "pasteAsPlaintext": "",
"pasteCharts": "Vložit grafy", "pasteCharts": "Vložit grafy",
"selectAll": "Vybrat vše", "selectAll": "Vybrat vše",
"multiSelect": "Přidat prvek do výběru", "multiSelect": "Přidat prvek do výběru",
@@ -49,9 +49,9 @@
"large": "Velké", "large": "Velké",
"veryLarge": "Velmi velké", "veryLarge": "Velmi velké",
"solid": "Plný", "solid": "Plný",
"hachure": "Hachure", "hachure": "",
"zigzag": "Klikatě", "zigzag": "",
"crossHatch": "Křížový šrafování", "crossHatch": "",
"thin": "Tenký", "thin": "Tenký",
"bold": "Tlustý", "bold": "Tlustý",
"left": "Vlevo", "left": "Vlevo",
@@ -60,7 +60,7 @@
"extraBold": "Extra tlustý", "extraBold": "Extra tlustý",
"architect": "Architekt", "architect": "Architekt",
"artist": "Umělec", "artist": "Umělec",
"cartoonist": "Kartoonista", "cartoonist": "",
"fileTitle": "Název souboru", "fileTitle": "Název souboru",
"colorPicker": "Výběr barvy", "colorPicker": "Výběr barvy",
"canvasColors": "Použito na plátně", "canvasColors": "Použito na plátně",
@@ -106,7 +106,7 @@
"increaseFontSize": "Zvětšit písmo", "increaseFontSize": "Zvětšit písmo",
"unbindText": "Zrušit vazbu textu", "unbindText": "Zrušit vazbu textu",
"bindText": "Vázat text s kontejnerem", "bindText": "Vázat text s kontejnerem",
"createContainerFromText": "Zabalit text do kontejneru", "createContainerFromText": "",
"link": { "link": {
"edit": "Upravit odkaz", "edit": "Upravit odkaz",
"create": "Vytvořit odkaz", "create": "Vytvořit odkaz",
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Zveřejněno", "statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený", "sidebarLock": "Ponechat postranní panel otevřený",
"eyeDropper": "Vyberte barvu z plátna" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Dosud neexistují žádné položky...", "noItems": "Dosud neexistují žádné položky...",
@@ -177,38 +177,38 @@
"decryptFailed": "Nelze dešifrovat data.", "decryptFailed": "Nelze dešifrovat data.",
"uploadedSecurly": "Nahrávání je zabezpečeno koncovým šifrováním, což znamená, že server Excalidraw ani třetí strany nemohou obsah přečíst.", "uploadedSecurly": "Nahrávání je zabezpečeno koncovým šifrováním, což znamená, že server Excalidraw ani třetí strany nemohou obsah přečíst.",
"loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?", "loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?",
"collabStopOverridePrompt": "Zastavení relace přepíše vaše předchozí, lokálně uložené kresby. Jste si jisti?\n\n(Pokud chcete zachovat místní kresbu, jednoduše zavřete kartu prohlížeče)", "collabStopOverridePrompt": "",
"errorAddingToLibrary": "Položku nelze přidat do knihovny", "errorAddingToLibrary": "Položku nelze přidat do knihovny",
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny", "errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
"confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?", "confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?",
"imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?", "imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?",
"cannotRestoreFromImage": "Scénu nelze obnovit z tohoto souboru obrázku", "cannotRestoreFromImage": "",
"invalidSceneUrl": "Nelze importovat scénu z zadané URL. Je buď poškozená, nebo neobsahuje platná JSON data Excalidraw.", "invalidSceneUrl": "",
"resetLibrary": "Tímto vymažete vaši knihovnu. Jste si jisti?", "resetLibrary": "",
"removeItemsFromsLibrary": "Smazat {{count}} položek z knihovny?", "removeItemsFromsLibrary": "",
"invalidEncryptionKey": "Šifrovací klíč musí mít 22 znaků. Live spolupráce je zakázána.", "invalidEncryptionKey": "",
"collabOfflineWarning": "Není k dispozici žádné internetové připojení.\nVaše změny nebudou uloženy!" "collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "Nepodporovaný typ souboru.", "unsupportedFileType": "",
"imageInsertError": "Nelze vložit obrázek. Zkuste to později...", "imageInsertError": "",
"fileTooBig": "Soubor je příliš velký. Maximální povolená velikost je {{maxSize}}.", "fileTooBig": "",
"svgImageInsertError": "Nelze vložit SVG obrázek. Značení SVG je neplatné.", "svgImageInsertError": "",
"invalidSVGString": "Neplatný SVG.", "invalidSVGString": "",
"cannotResolveCollabServer": "Nelze se připojit ke sdílenému serveru. Prosím obnovte stránku a zkuste to znovu.", "cannotResolveCollabServer": "",
"importLibraryError": "Nelze načíst knihovnu", "importLibraryError": "",
"collabSaveFailed": "Nelze uložit do databáze na serveru. Pokud problémy přetrvávají, měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "Nelze uložit do databáze na serveru, plátno se zdá být příliš velké. Měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"line1": "Vypadá to, že používáte Brave prohlížeč s povoleným nastavením <bold>Aggressively Block Fingerprinting</bold>.", "line1": "",
"line2": "To by mohlo vést k narušení <bold>Textových elementů</bold> ve vašich výkresech.", "line2": "",
"line3": "Důrazně doporučujeme zakázat toto nastavení. Můžete sledovat <link>tyto kroky</link> jak to udělat.", "line3": "",
"line4": "Pokud vypnutí tohoto nastavení neopravuje zobrazení textových prvků, prosím, otevřete <issueLink>problém</issueLink> na našem GitHubu, nebo nám napište na <discordLink>Discord</discordLink>" "line4": ""
} }
}, },
"toolBar": { "toolBar": {
"selection": "Výběr", "selection": "Výběr",
"image": "Vložit obrázek", "image": "",
"rectangle": "Obdélník", "rectangle": "Obdélník",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Elipsa", "ellipse": "Elipsa",
@@ -216,69 +216,69 @@
"line": "Čára", "line": "Čára",
"freedraw": "Kreslení", "freedraw": "Kreslení",
"text": "Text", "text": "Text",
"library": "Knihovna", "library": "",
"lock": "Po kreslení ponechat vybraný nástroj aktivní", "lock": "",
"penMode": "Režim Pera - zabránit dotyku", "penMode": "",
"link": "Přidat/aktualizovat odkaz pro vybraný tvar", "link": "",
"eraser": "Guma", "eraser": "Guma",
"hand": "Ruka (nástroj pro posouvání)" "hand": ""
}, },
"headings": { "headings": {
"canvasActions": "Akce plátna", "canvasActions": "",
"selectedShapeActions": "Akce vybraného tvaru", "selectedShapeActions": "",
"shapes": "Tvary" "shapes": "Tvary"
}, },
"hints": { "hints": {
"canvasPanning": "Chcete-li přesunout plátno, podržte kolečko nebo mezerník při tažení nebo použijte nástroj Ruka", "canvasPanning": "",
"linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru", "linearElement": "",
"freeDraw": "Klikněte a táhněte, pro ukončení pusťte", "freeDraw": "",
"text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr", "text": "",
"text_selected": "Dvojklikem nebo stisknutím klávesy ENTER upravíte text", "text_selected": "",
"text_editing": "Stiskněte Escape nebo Ctrl/Cmd+ENTER pro dokončení úprav", "text_editing": "",
"linearElementMulti": "Klikněte na poslední bod nebo stiskněte Escape anebo Enter pro dokončení", "linearElementMulti": "",
"lockAngle": "Úhel můžete omezit podržením SHIFT", "lockAngle": "",
"resize": "Můžete omezit proporce podržením SHIFT při změně velikosti,\npodržte ALT pro změnu velikosti od středu", "resize": "",
"resizeImage": "Můžete volně změnit velikost podržením SHIFT,\npodržením klávesy ALT změníte velikosti od středu", "resizeImage": "",
"rotate": "Úhly můžete omezit podržením SHIFT při otáčení", "rotate": "",
"lineEditor_info": "Podržte Ctrl/Cmd a dvakrát klikněte nebo stiskněte Ctrl/Cmd + Enter pro úpravu bodů", "lineEditor_info": "",
"lineEditor_pointSelected": "Stisknutím tlačítka Delete odstraňte bod(y),\nCtrl/Cmd+D pro duplicitu nebo táhnutím pro přesun", "lineEditor_pointSelected": "",
"lineEditor_nothingSelected": "Vyberte bod, který chcete upravit (podržením klávesy SHIFT vyberete více položek),\nnebo podržením klávesy Alt a kliknutím přidáte nové body", "lineEditor_nothingSelected": "",
"placeImage": "Kliknutím umístěte obrázek, nebo klepnutím a přetažením ručně nastavíte jeho velikost", "placeImage": "",
"publishLibrary": "Publikovat vlastní knihovnu", "publishLibrary": "",
"bindTextToElement": "Stiskněte Enter pro přidání textu", "bindTextToElement": "",
"deepBoxSelect": "Podržte Ctrl/Cmd pro hluboký výběr a pro zabránění táhnutí", "deepBoxSelect": "",
"eraserRevert": "Podržením klávesy Alt vrátíte prvky označené pro smazání", "eraserRevert": "",
"firefox_clipboard_write": "Tato funkce může být povolena nastavením vlajky \"dom.events.asyncClipboard.clipboardItem\" na \"true\". Chcete-li změnit vlajky prohlížeče ve Firefoxu, navštivte stránku \"about:config\"." "firefox_clipboard_write": ""
}, },
"canvasError": { "canvasError": {
"cannotShowPreview": "Náhled nelze zobrazit", "cannotShowPreview": "",
"canvasTooBig": "Plátno je možná příliš velké.", "canvasTooBig": "",
"canvasTooBigTip": "Tip: zkus posunout nejvzdálenější prvky trochu blíže k sobě." "canvasTooBigTip": ""
}, },
"errorSplash": { "errorSplash": {
"headingMain": "Chyba. Zkuste <button>znovu načíst stránku</button>.", "headingMain": "",
"clearCanvasMessage": "Pokud opětovné načtení nefunguje, zkuste <button>vymazat plátno</button>.", "clearCanvasMessage": "",
"clearCanvasCaveat": " To povede ke ztrátě dat ", "clearCanvasCaveat": "",
"trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.", "trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
"openIssueMessage": "Byli jsme velmi opatrní, abychom neuváděli informace o Vaší scéně. Pokud vaše scéna není soukromá, zvažte prosím sledování na našem <button>bug trackeru</button>. Uveďte prosím níže uvedené informace kopírováním a vložením do problému na GitHubu.", "openIssueMessage": "",
"sceneContent": "Obsah scény:" "sceneContent": ""
}, },
"roomDialog": { "roomDialog": {
"desc_intro": "Můžete pozvat lidi na vaši aktuální scénu ke spolupráci s vámi.", "desc_intro": "",
"desc_privacy": "Nebojte se, relace používá end-to-end šifrování, takže cokoliv nakreslíte zůstane soukromé. Ani náš server nebude schopen vidět, s čím budete pracovat.", "desc_privacy": "",
"button_startSession": "Zahájit relaci", "button_startSession": "",
"button_stopSession": "Ukončit relaci", "button_stopSession": "",
"desc_inProgressIntro": "Živá spolupráce právě probíhá.", "desc_inProgressIntro": "",
"desc_shareLink": "Sdílejte tento odkaz s každým, s kým chcete spolupracovat:", "desc_shareLink": "",
"desc_exitSession": "Zastavením relace se odpojíte od místnosti, ale budete moci pokračovat v práci s touto scénou lokálně. Všimněte si, že to nebude mít vliv na ostatní lidi a budou stále moci spolupracovat na jejich verzi.", "desc_exitSession": "",
"shareTitle": "Připojte se k aktivní spolupráci na Excalidraw" "shareTitle": ""
}, },
"errorDialog": { "errorDialog": {
"title": "Chyba" "title": ""
}, },
"exportDialog": { "exportDialog": {
"disk_title": "Uložit na disk", "disk_title": "",
"disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.", "disk_details": "",
"disk_button": "Uložit do souboru", "disk_button": "Uložit do souboru",
"link_title": "Odkaz pro sdílení", "link_title": "Odkaz pro sdílení",
"link_details": "Exportovat jako odkaz pouze pro čtení.", "link_details": "Exportovat jako odkaz pouze pro čtení.",
@@ -290,18 +290,18 @@
"helpDialog": { "helpDialog": {
"blog": "Přečtěte si náš blog", "blog": "Přečtěte si náš blog",
"click": "kliknutí", "click": "kliknutí",
"deepSelect": "Hluboký výběr", "deepSelect": "",
"deepBoxSelect": "Hluboký výběr uvnitř boxu a zabránění táhnnutí", "deepBoxSelect": "",
"curvedArrow": "Zakřivená šipka", "curvedArrow": "Zakřivená šipka",
"curvedLine": "Zakřivená čára", "curvedLine": "Zakřivená čára",
"documentation": "Dokumentace", "documentation": "Dokumentace",
"doubleClick": "dvojklik", "doubleClick": "dvojklik",
"drag": "tažení", "drag": "tažení",
"editor": "Editor", "editor": "Editor",
"editLineArrowPoints": "Upravit body linií/šipek", "editLineArrowPoints": "",
"editText": "Upravit text / přidat popis", "editText": "",
"github": "Našel jsi problém? Nahlaš ho", "github": "",
"howto": "Sledujte naše návody", "howto": "",
"or": "nebo", "or": "nebo",
"preventBinding": "Zabránit vázání šipky", "preventBinding": "Zabránit vázání šipky",
"tools": "Nástroje", "tools": "Nástroje",
@@ -313,8 +313,8 @@
"zoomToFit": "Přiblížit na zobrazení všech prvků", "zoomToFit": "Přiblížit na zobrazení všech prvků",
"zoomToSelection": "Přiblížit na výběr", "zoomToSelection": "Přiblížit na výběr",
"toggleElementLock": "Zamknout/odemknout výběr", "toggleElementLock": "Zamknout/odemknout výběr",
"movePageUpDown": "Posunout stránku nahoru/dolů", "movePageUpDown": "",
"movePageLeftRight": "Přesunout stránku doleva/doprava" "movePageLeftRight": ""
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Vymazat plátno" "title": "Vymazat plátno"
@@ -342,46 +342,46 @@
}, },
"noteDescription": "Odešlete svou knihovnu, pro zařazení do <link>veřejného úložiště knihoven</link>, odkud ji budou moci při kreslení využít i ostatní uživatelé.", "noteDescription": "Odešlete svou knihovnu, pro zařazení do <link>veřejného úložiště knihoven</link>, odkud ji budou moci při kreslení využít i ostatní uživatelé.",
"noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>", "noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
"noteLicense": "Odesláním souhlasíte s tím, že knihovna bude zveřejněna pod <link>MIT licencí</link>, stručně řečeno, kdokoli ji může používat bez omezení.", "noteLicense": "",
"noteItems": "Každá položka knihovny musí mít svůj vlastní název, aby byla filtrovatelná. Následující položky knihovny budou zahrnuty:", "noteItems": "",
"atleastOneLibItem": "Vyberte alespoň jednu položku knihovny, kterou chcete začít", "atleastOneLibItem": "",
"republishWarning": "Poznámka: některé z vybraných položek jsou označeny jako již zveřejněné/odeslané. Položky byste měli znovu odeslat pouze při aktualizaci existující knihovny nebo podání." "republishWarning": ""
}, },
"publishSuccessDialog": { "publishSuccessDialog": {
"title": "Knihovna byla odeslána", "title": "Knihovna byla odeslána",
"content": "Děkujeme vám {{authorName}}. Vaše knihovna byla odeslána k posouzení. Stav můžete sledovat <link>zde</link>" "content": ""
}, },
"confirmDialog": { "confirmDialog": {
"resetLibrary": "Resetovat knihovnu", "resetLibrary": "",
"removeItemsFromLib": "Odstranit vybrané položky z knihovny" "removeItemsFromLib": ""
}, },
"imageExportDialog": { "imageExportDialog": {
"header": "Exportovat obrázek", "header": "",
"label": { "label": {
"withBackground": "Pozadí", "withBackground": "",
"onlySelected": "Pouze vybrané", "onlySelected": "",
"darkMode": "Tmavý režim", "darkMode": "",
"embedScene": "Vložit scénu", "embedScene": "",
"scale": "Měřítko", "scale": "",
"padding": "Odsazení" "padding": ""
}, },
"tooltip": { "tooltip": {
"embedScene": "Data scény budou uložena do exportovaného souboru PNG/SVG tak, aby z něj mohla být scéna obnovena.\nZvýší se velikost exportovaného souboru." "embedScene": ""
}, },
"title": { "title": {
"exportToPng": "Exportovat do PNG", "exportToPng": "",
"exportToSvg": "Exportovat do SVG", "exportToSvg": "",
"copyPngToClipboard": "Kopírovat PNG do schránky" "copyPngToClipboard": ""
}, },
"button": { "button": {
"exportToPng": "PNG", "exportToPng": "",
"exportToSvg": "SVG", "exportToSvg": "",
"copyPngToClipboard": "Kopírovat do schránky" "copyPngToClipboard": ""
} }
}, },
"encrypted": { "encrypted": {
"tooltip": "Vaše kresby jsou end-to-end šifrované, takže servery Excalidraw je nikdy neuvidí.", "tooltip": "",
"link": "Blog příspěvek na end-to-end šifrování v Excalidraw" "link": ""
}, },
"stats": { "stats": {
"angle": "Úhel", "angle": "Úhel",
@@ -402,48 +402,48 @@
"addedToLibrary": "Přidáno do knihovny", "addedToLibrary": "Přidáno do knihovny",
"copyStyles": "Styly byly zkopírovány.", "copyStyles": "Styly byly zkopírovány.",
"copyToClipboard": "Zkopírováno do schránky.", "copyToClipboard": "Zkopírováno do schránky.",
"copyToClipboardAsPng": "{{exportSelection}} zkopírován do schránky jako PNG\n({{exportColorScheme}})", "copyToClipboardAsPng": "",
"fileSaved": "Soubor byl uložen.", "fileSaved": "Soubor byl uložen.",
"fileSavedToFilename": "Uloženo do {filename}", "fileSavedToFilename": "Uloženo do {filename}",
"canvas": "plátno", "canvas": "plátno",
"selection": "výběr", "selection": "výběr",
"pasteAsSingleElement": "Pomocí {{shortcut}} vložte jako jeden prvek,\nnebo vložte do existujícího textového editoru" "pasteAsSingleElement": ""
}, },
"colors": { "colors": {
"transparent": "Průhledná", "transparent": "Průhledná",
"black": "Černá", "black": "",
"white": "Bílá", "white": "",
"red": "Červená", "red": "",
"pink": "Růžová", "pink": "",
"grape": "Vínová", "grape": "",
"violet": "Fialová", "violet": "",
"gray": "Šedá", "gray": "",
"blue": "Modrá", "blue": "",
"cyan": "Azurová", "cyan": "",
"teal": "Modrozelená", "teal": "",
"green": "Zelená", "green": "",
"yellow": "Žlutá", "yellow": "",
"orange": "Oranžová", "orange": "",
"bronze": "Bronzová" "bronze": ""
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.", "center_heading": "",
"center_heading_plus": "Chcete místo toho přejít na Excalidraw+?", "center_heading_plus": "",
"menuHint": "Export, nastavení, jazyky, ..." "menuHint": ""
}, },
"defaults": { "defaults": {
"menuHint": "Export, nastavení a další...", "menuHint": "",
"center_heading": "Diagramy. Vytvořeny. Jednoduše.", "center_heading": "",
"toolbarHint": "Vyberte nástroj a začněte kreslit!", "toolbarHint": "",
"helpHint": "Zkratky a pomoc" "helpHint": ""
} }
}, },
"colorPicker": { "colorPicker": {
"mostUsedCustomColors": "Nejpoužívanější vlastní barvy", "mostUsedCustomColors": "",
"colors": "Barvy", "colors": "",
"shades": "Stíny", "shades": "",
"hexCode": "Hex kód", "hexCode": "",
"noShades": "Pro tuto barvu nejsou k dispozici žádné odstíny" "noShades": ""
} }
} }
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Veröffentlicht", "statusPublished": "Veröffentlicht",
"sidebarLock": "Seitenleiste offen lassen", "sidebarLock": "Seitenleiste offen lassen",
"eyeDropper": "Farbe von der Zeichenfläche auswählen" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Noch keine Elemente hinzugefügt...", "noItems": "Noch keine Elemente hinzugefügt...",
+1 -5
View File
@@ -124,8 +124,6 @@
}, },
"statusPublished": "Published", "statusPublished": "Published",
"sidebarLock": "Keep sidebar open", "sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas" "eyeDropper": "Pick color from canvas"
}, },
"library": { "library": {
@@ -223,9 +221,7 @@
"penMode": "Pen mode - prevent touch", "penMode": "Pen mode - prevent touch",
"link": "Add/ Update link for a selected shape", "link": "Add/ Update link for a selected shape",
"eraser": "Eraser", "eraser": "Eraser",
"frame": "Frame tool", "hand": "Hand (panning tool)"
"hand": "Hand (panning tool)",
"extraTools": "More tools"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "प्रकाशित", "statusPublished": "प्रकाशित",
"sidebarLock": "साइडबार खुला रखे.", "sidebarLock": "साइडबार खुला रखे.",
"eyeDropper": "चित्रफलक से रंग चुने" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "अभी तक कोई आइटम जोडा नहीं गया.", "noItems": "अभी तक कोई आइटम जोडा नहीं गया.",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "게시됨", "statusPublished": "게시됨",
"sidebarLock": "사이드바 유지", "sidebarLock": "사이드바 유지",
"eyeDropper": "캔버스에서 색상 고르기" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "추가된 아이템 없음", "noItems": "추가된 아이템 없음",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "प्रकाशित करा", "statusPublished": "प्रकाशित करा",
"sidebarLock": "साइडबार उघडं ठेवा", "sidebarLock": "साइडबार उघडं ठेवा",
"eyeDropper": "चित्रफलकातून रंग निवडा" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "अजून कोणतेही आइटम जोडलेले नाही...", "noItems": "अजून कोणतेही आइटम जोडलेले नाही...",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Publisert", "statusPublished": "Publisert",
"sidebarLock": "Holde sidemenyen åpen", "sidebarLock": "Holde sidemenyen åpen",
"eyeDropper": "Velg farge fra lerretet" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Ingen elementer lagt til ennå...", "noItems": "Ingen elementer lagt til ennå...",
+14 -15
View File
@@ -1,12 +1,11 @@
{ {
"ar-SA": 80, "ar-SA": 80,
"az-AZ": 20,
"bg-BG": 54, "bg-BG": 54,
"bn-BD": 60, "bn-BD": 60,
"ca-ES": 88, "ca-ES": 88,
"cs-CZ": 100, "cs-CZ": 64,
"da-DK": 33, "da-DK": 33,
"de-DE": 100, "de-DE": 99,
"el-GR": 93, "el-GR": 93,
"en": 100, "en": 100,
"es-ES": 89, "es-ES": 89,
@@ -25,32 +24,32 @@
"kab-KAB": 88, "kab-KAB": 88,
"kk-KZ": 21, "kk-KZ": 21,
"km-KH": 95, "km-KH": 95,
"ko-KR": 100, "ko-KR": 99,
"ku-TR": 90, "ku-TR": 90,
"lt-LT": 56, "lt-LT": 56,
"lv-LV": 89, "lv-LV": 89,
"mr-IN": 96, "mr-IN": 95,
"my-MM": 41, "my-MM": 41,
"nb-NO": 100, "nb-NO": 99,
"nl-NL": 83, "nl-NL": 83,
"nn-NO": 77, "nn-NO": 77,
"oc-FR": 87, "oc-FR": 87,
"pa-IN": 90, "pa-IN": 90,
"pl-PL": 100, "pl-PL": 99,
"pt-BR": 99, "pt-BR": 99,
"pt-PT": 95, "pt-PT": 95,
"ro-RO": 100, "ro-RO": 99,
"ru-RU": 100, "ru-RU": 99,
"si-LK": 9, "si-LK": 9,
"sk-SK": 100, "sk-SK": 99,
"sl-SI": 100, "sl-SI": 99,
"sv-SE": 100, "sv-SE": 99,
"ta-IN": 85, "ta-IN": 83,
"th-TH": 39, "th-TH": 39,
"tr-TR": 87, "tr-TR": 87,
"uk-UA": 97, "uk-UA": 97,
"vi-VN": 56, "vi-VN": 56,
"zh-CN": 100, "zh-CN": 95,
"zh-HK": 26, "zh-HK": 26,
"zh-TW": 100 "zh-TW": 99
} }
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Opublikowano", "statusPublished": "Opublikowano",
"sidebarLock": "Panel boczny zawsze otwarty", "sidebarLock": "Panel boczny zawsze otwarty",
"eyeDropper": "Wybierz kolor z płótna" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Nie dodano jeszcze żadnych elementów...", "noItems": "Nie dodano jeszcze żadnych elementów...",
+1 -1
View File
@@ -363,7 +363,7 @@
"darkMode": "", "darkMode": "",
"embedScene": "Cena embutida", "embedScene": "Cena embutida",
"scale": "", "scale": "",
"padding": "Espaçamento" "padding": ""
}, },
"tooltip": { "tooltip": {
"embedScene": "" "embedScene": ""
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Publicat", "statusPublished": "Publicat",
"sidebarLock": "Păstrează deschisă bara laterală", "sidebarLock": "Păstrează deschisă bara laterală",
"eyeDropper": "Alegere culoare din pânză" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Niciun element adăugat încă...", "noItems": "Niciun element adăugat încă...",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Опубликовано", "statusPublished": "Опубликовано",
"sidebarLock": "Держать боковую панель открытой", "sidebarLock": "Держать боковую панель открытой",
"eyeDropper": "Взять образец цвета с холста" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Пока ничего не добавлено...", "noItems": "Пока ничего не добавлено...",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Zverejnené", "statusPublished": "Zverejnené",
"sidebarLock": "Nechať bočný panel otvorený", "sidebarLock": "Nechať bočný panel otvorený",
"eyeDropper": "Vybrať farbu z plátna" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Zatiaľ neboli pridané žiadne položky...", "noItems": "Zatiaľ neboli pridané žiadne položky...",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Objavljeno", "statusPublished": "Objavljeno",
"sidebarLock": "Obdrži stransko vrstico odprto", "sidebarLock": "Obdrži stransko vrstico odprto",
"eyeDropper": "Izberi barvo s platna" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Dodan še ni noben element...", "noItems": "Dodan še ni noben element...",
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "Publicerad", "statusPublished": "Publicerad",
"sidebarLock": "Håll sidofältet öppet", "sidebarLock": "Håll sidofältet öppet",
"eyeDropper": "Välj färg från canvas" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "Inga objekt tillagda ännu...", "noItems": "Inga objekt tillagda ännu...",
+10 -10
View File
@@ -50,7 +50,7 @@
"veryLarge": "மிகப் பெரிய", "veryLarge": "மிகப் பெரிய",
"solid": "திடமான", "solid": "திடமான",
"hachure": "மலைக்குறிக்கோடு", "hachure": "மலைக்குறிக்கோடு",
"zigzag": "கோணல்மாணல்", "zigzag": "",
"crossHatch": "குறுக்குகதவு", "crossHatch": "குறுக்குகதவு",
"thin": "மெல்லிய", "thin": "மெல்லிய",
"bold": "பட்டை", "bold": "பட்டை",
@@ -105,8 +105,8 @@
"decreaseFontSize": "எழுத்துரு அளவைக் குறை", "decreaseFontSize": "எழுத்துரு அளவைக் குறை",
"increaseFontSize": "எழுத்துரு அளவை அதிகரி", "increaseFontSize": "எழுத்துரு அளவை அதிகரி",
"unbindText": "உரையைப் பிணைவவிழ்", "unbindText": "உரையைப் பிணைவவிழ்",
"bindText": "உரையைக் கொள்கலனுக்குப் பிணை", "bindText": "",
"createContainerFromText": "உரையைக் கொள்கலனுள் சுருட்டு", "createContainerFromText": "",
"link": { "link": {
"edit": "தொடுப்பைத் திருத்து", "edit": "தொடுப்பைத் திருத்து",
"create": "தொடுப்பைப் படை", "create": "தொடுப்பைப் படை",
@@ -114,7 +114,7 @@
}, },
"lineEditor": { "lineEditor": {
"edit": "தொடுப்பைத் திருத்து", "edit": "தொடுப்பைத் திருத்து",
"exit": "வரி திருத்தியிலிருந்து வெளியேறு" "exit": ""
}, },
"elementLock": { "elementLock": {
"lock": "பூட்டு", "lock": "பூட்டு",
@@ -124,7 +124,7 @@
}, },
"statusPublished": "வெளியிடப்பட்டது", "statusPublished": "வெளியிடப்பட்டது",
"sidebarLock": "பக்கப்பட்டையைத் திறந்தே வை", "sidebarLock": "பக்கப்பட்டையைத் திறந்தே வை",
"eyeDropper": "கித்தானிலிருந்து நிறம் தேர்ந்தெடு" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "இதுவரை உருப்படிகள் சேரக்கப்படவில்லை...", "noItems": "இதுவரை உருப்படிகள் சேரக்கப்படவில்லை...",
@@ -134,7 +134,7 @@
"buttons": { "buttons": {
"clearReset": "கித்தானை அகரமாக்கு", "clearReset": "கித்தானை அகரமாக்கு",
"exportJSON": "கோப்புக்கு ஏற்றுமதிசெய்", "exportJSON": "கோப்புக்கு ஏற்றுமதிசெய்",
"exportImage": "படத்தை ஏற்றுமதிசெய்...", "exportImage": "",
"export": "இதில் சேமி...", "export": "இதில் சேமி...",
"copyToClipboard": "நகலகத்திற்கு நகலெடு", "copyToClipboard": "நகலகத்திற்கு நகலெடு",
"save": "தற்போதைய கோப்புக்குச் சேமி", "save": "தற்போதைய கோப்புக்குச் சேமி",
@@ -187,7 +187,7 @@
"resetLibrary": "இது உங்கள் நுலகத்தைத் துடைக்கும். நீங்கள் உறுதியா?", "resetLibrary": "இது உங்கள் நுலகத்தைத் துடைக்கும். நீங்கள் உறுதியா?",
"removeItemsFromsLibrary": "{{count}} உருப்படி(கள்)-ஐ உம் நூலகத்திலிருந்து அழிக்கவா?", "removeItemsFromsLibrary": "{{count}} உருப்படி(கள்)-ஐ உம் நூலகத்திலிருந்து அழிக்கவா?",
"invalidEncryptionKey": "மறையாக்க விசை 22 வரியுருக்கள் கொண்டிருக்கவேண்டும். நேரடி கூட்டுப்பணி முடக்கப்பட்டது.", "invalidEncryptionKey": "மறையாக்க விசை 22 வரியுருக்கள் கொண்டிருக்கவேண்டும். நேரடி கூட்டுப்பணி முடக்கப்பட்டது.",
"collabOfflineWarning": "இணைய இணைப்பு இல்லை.\nஉமது மாற்றங்கள் சேமிக்கப்படா!" "collabOfflineWarning": ""
}, },
"errors": { "errors": {
"unsupportedFileType": "ஆதரிக்கப்படா கோப்பு வகை.", "unsupportedFileType": "ஆதரிக்கப்படா கோப்பு வகை.",
@@ -195,10 +195,10 @@
"fileTooBig": "கோப்பு மிகப்பெரிது. அனுமதிக்கப்பட்ட அதிகபட்ச அளவு {{maxSize}}.", "fileTooBig": "கோப்பு மிகப்பெரிது. அனுமதிக்கப்பட்ட அதிகபட்ச அளவு {{maxSize}}.",
"svgImageInsertError": "எஸ்விஜி படத்தைப் புகுத்தவியலா. எஸ்விஜியின் மார்க்அப் செல்லாததாக தெரிகிறது.", "svgImageInsertError": "எஸ்விஜி படத்தைப் புகுத்தவியலா. எஸ்விஜியின் மார்க்அப் செல்லாததாக தெரிகிறது.",
"invalidSVGString": "செல்லாத SVG.", "invalidSVGString": "செல்லாத SVG.",
"cannotResolveCollabServer": "கூட்டுப்பணிச் சேவையகத்துடன் இணைக்க முடியவில்லை. பக்கத்தை மீளேற்றி மீண்டும் முயலவும்.", "cannotResolveCollabServer": "",
"importLibraryError": "நூலகத்தை ஏற்ற முடியவில்லை", "importLibraryError": "நூலகத்தை ஏற்ற முடியவில்லை",
"collabSaveFailed": "பின்முனை தரவுத்தளத்தில் சேமிக்க முடியவில்லை. சிக்கல்கள் நீடித்தால், உமது வேலைகளை இழக்காமலிருப்பதை உறுதிசெய்ய உமது கோப்பை உள்ளகத்தில் சேமிக்க வேண்டும்.", "collabSaveFailed": "",
"collabSaveFailed_sizeExceeded": "பின்முனை தரவுத்தளத்தில் சேமிக்க முடியவில்லை, கித்தான் மிகப்பெரிதாகத் தெரிகிறது. உமது வேலைகளை இழக்காமலிருப்பதை உறுதிசெய்ய உமது கோப்பை உள்ளகத்தில் சேமிக்க வேண்டும்.", "collabSaveFailed_sizeExceeded": "",
"brave_measure_text_error": { "brave_measure_text_error": {
"line1": "", "line1": "",
"line2": "", "line2": "",
+20 -20
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "已发布", "statusPublished": "已发布",
"sidebarLock": "侧边栏常驻", "sidebarLock": "侧边栏常驻",
"eyeDropper": "从画布上取色" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "尚未添加任何项目……", "noItems": "尚未添加任何项目……",
@@ -266,12 +266,12 @@
"roomDialog": { "roomDialog": {
"desc_intro": "你可以邀请其他人到目前的画面中与你协作。", "desc_intro": "你可以邀请其他人到目前的画面中与你协作。",
"desc_privacy": "别担心,该会话使用端到端加密,无论绘制什么都将保持私密,甚至连我们的服务器也无法查看。", "desc_privacy": "别担心,该会话使用端到端加密,无论绘制什么都将保持私密,甚至连我们的服务器也无法查看。",
"button_startSession": "开始会话", "button_startSession": "启动会议",
"button_stopSession": "结束会话", "button_stopSession": "结束会议",
"desc_inProgressIntro": "实时协作会话进行中。", "desc_inProgressIntro": "实时协作会议正在进行。",
"desc_shareLink": "分享此链接给你要协作的用户", "desc_shareLink": "分享此链接给你要协作的用户",
"desc_exitSession": "停止会话将中断您与房间的连接,但您依然可以在本地继续使用画布。请注意,这不会影响到其他用户,他们仍可以在他们的版本上继续协作。", "desc_exitSession": "停止会话将中断您在与房间的连接,但您依然可以在本地继续使用画布. 请注意,这不会影响到其他用户, 他们仍可以在他们的版本上继续协作。",
"shareTitle": "加入 Excalidraw 实时协作会话" "shareTitle": "加入 Excalidraw 实时协作会议"
}, },
"errorDialog": { "errorDialog": {
"title": "错误" "title": "错误"
@@ -356,27 +356,27 @@
"removeItemsFromLib": "从素材库中删除选中的项目" "removeItemsFromLib": "从素材库中删除选中的项目"
}, },
"imageExportDialog": { "imageExportDialog": {
"header": "导出图片", "header": "",
"label": { "label": {
"withBackground": "背景", "withBackground": "",
"onlySelected": "仅选中", "onlySelected": "",
"darkMode": "深色模式", "darkMode": "",
"embedScene": "包含画布数据", "embedScene": "",
"scale": "缩放比例", "scale": "",
"padding": "内边距" "padding": ""
}, },
"tooltip": { "tooltip": {
"embedScene": "画布数据将被保存到导出的 PNG/SVG 文件,以便恢复。\n将会增加导出文件的大小。" "embedScene": ""
}, },
"title": { "title": {
"exportToPng": "导出为 PNG", "exportToPng": "",
"exportToSvg": "导出为 SVG", "exportToSvg": "",
"copyPngToClipboard": "复制 PNG 到剪切板" "copyPngToClipboard": ""
}, },
"button": { "button": {
"exportToPng": "PNG", "exportToPng": "",
"exportToSvg": "SVG", "exportToSvg": "",
"copyPngToClipboard": "复制到剪贴板" "copyPngToClipboard": ""
} }
}, },
"encrypted": { "encrypted": {
+1 -1
View File
@@ -124,7 +124,7 @@
}, },
"statusPublished": "已發布", "statusPublished": "已發布",
"sidebarLock": "側欄維持開啟", "sidebarLock": "側欄維持開啟",
"eyeDropper": "從畫布中選取顏色" "eyeDropper": ""
}, },
"library": { "library": {
"noItems": "尚未加入任何物件...", "noItems": "尚未加入任何物件...",
+1 -1
View File
@@ -206,7 +206,7 @@ export const isPointInPolygon = (
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
// This is an approximation to "does `q` lie on a segment `pr`" check. // This is an approximation to "does `q` lie on a segment `pr`" check.
export const isPointWithinBounds = (p: Point, q: Point, r: Point) => { const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
return ( return (
q[0] <= Math.max(p[0], r[0]) && q[0] <= Math.max(p[0], r[0]) &&
q[0] >= Math.min(p[0], r[0]) && q[0] >= Math.min(p[0], r[0]) &&
-1
View File
@@ -205,7 +205,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
height: 141.9765625, height: 141.9765625,
seed: 1968410350, seed: 1968410350,
groupIds: [], groupIds: [],
frameId: null,
boundElements: null, boundElements: null,
locked: false, locked: false,
link: null, link: null,
@@ -20,7 +20,6 @@ export default {
height: 141.9765625, height: 141.9765625,
seed: 1968410350, seed: 1968410350,
groupIds: [], groupIds: [],
frameId: null,
}, },
{ {
id: "-xMIs_0jIFqvpx-R9UnaG", id: "-xMIs_0jIFqvpx-R9UnaG",
@@ -38,7 +37,6 @@ export default {
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: [],
frameId: null,
seed: 957947807, seed: 957947807,
version: 47, version: 47,
versionNonce: 1128618623, versionNonce: 1128618623,
@@ -60,7 +58,6 @@ export default {
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: [],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
seed: 707269846, seed: 707269846,
version: 143, version: 143,
@@ -97,7 +94,6 @@ export default {
height: 103.65107323746608, height: 103.65107323746608,
seed: 1445523839, seed: 1445523839,
groupIds: [], groupIds: [],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -137,7 +133,6 @@ export default {
height: 113.8575037534261, height: 113.8575037534261,
seed: 1513238033, seed: 1513238033,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -187,7 +182,6 @@ export default {
height: 9.797916664247975, height: 9.797916664247975,
seed: 683951089, seed: 683951089,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -226,7 +220,6 @@ export default {
height: 9.797916664247975, height: 9.797916664247975,
seed: 1817746897, seed: 1817746897,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -265,7 +258,6 @@ export default {
height: 17.72670397681366, height: 17.72670397681366,
seed: 1409727409, seed: 1409727409,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"], boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
}, },
@@ -289,7 +281,6 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 1073094033, seed: 1073094033,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -313,7 +304,6 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 526271345, seed: 526271345,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -337,7 +327,6 @@ export default {
height: 13.941904362416096, height: 13.941904362416096,
seed: 243707217, seed: 243707217,
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"], groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -363,7 +352,6 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 511870335, seed: 511870335,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -387,7 +375,6 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 1283079231, seed: 1283079231,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -411,7 +398,6 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 996251633, seed: 996251633,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -435,7 +421,6 @@ export default {
height: 36.77344700318558, height: 36.77344700318558,
seed: 1764842481, seed: 1764842481,
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"], groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -461,7 +446,6 @@ export default {
height: 154.56722543646003, height: 154.56722543646003,
seed: 1424381745, seed: 1424381745,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -511,7 +495,6 @@ export default {
height: 12.698053371678215, height: 12.698053371678215,
seed: 726657713, seed: 726657713,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -550,7 +533,6 @@ export default {
height: 10.178760037658167, height: 10.178760037658167,
seed: 1977326481, seed: 1977326481,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -589,7 +571,6 @@ export default {
height: 22.797152568995934, height: 22.797152568995934,
seed: 1774660383, seed: 1774660383,
groupIds: ["HSrtfEf-CssQTf160Fb6R"], groupIds: ["HSrtfEf-CssQTf160Fb6R"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"], boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
}, },
@@ -615,7 +596,6 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 371096063, seed: 371096063,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -643,7 +623,6 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 685932433, seed: 685932433,
groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"], groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -671,7 +650,6 @@ export default {
height: 107.25081879410921, height: 107.25081879410921,
seed: 58634943, seed: 58634943,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [ boundElementIds: [
"CFu0B4Mw_1wC1Hbgx8Fs0", "CFu0B4Mw_1wC1Hbgx8Fs0",
@@ -699,7 +677,6 @@ export default {
height: 3.249953844290203, height: 3.249953844290203,
seed: 1673003743, seed: 1673003743,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -731,7 +708,6 @@ export default {
height: 2.8032978840147194, height: 2.8032978840147194,
seed: 1821527807, seed: 1821527807,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -763,7 +739,6 @@ export default {
height: 4.280657518731036, height: 4.280657518731036,
seed: 1485707039, seed: 1485707039,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -796,7 +771,6 @@ export default {
height: 2.9096445412231735, height: 2.9096445412231735,
seed: 1042012991, seed: 1042012991,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -830,7 +804,6 @@ export default {
height: 2.4757501798128, height: 2.4757501798128,
seed: 295443295, seed: 295443295,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -862,7 +835,6 @@ export default {
height: 2.4757501798128, height: 2.4757501798128,
seed: 1734301567, seed: 1734301567,
groupIds: ["9ppmKFUbA4iKjt8FaDFox"], groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
points: [ points: [
@@ -897,7 +869,6 @@ export default {
height: 76.53703389977764, height: 76.53703389977764,
seed: 106569279, seed: 106569279,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -921,7 +892,6 @@ export default {
height: 0, height: 0,
seed: 73916127, seed: 73916127,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -954,7 +924,6 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 387857791, seed: 387857791,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -978,7 +947,6 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 1486370207, seed: 1486370207,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1002,7 +970,6 @@ export default {
height: 5.001953125, height: 5.001953125,
seed: 610150847, seed: 610150847,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1026,7 +993,6 @@ export default {
height: 42.72020253937572, height: 42.72020253937572,
seed: 144280593, seed: 144280593,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1050,7 +1016,6 @@ export default {
height: 24.44112284281997, height: 24.44112284281997,
seed: 29167967, seed: 29167967,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -1103,7 +1068,6 @@ export default {
height: 0, height: 0,
seed: 1443027377, seed: 1443027377,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -1136,7 +1100,6 @@ export default {
height: 5.711199931375845, height: 5.711199931375845,
seed: 244310513, seed: 244310513,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -1175,7 +1138,6 @@ export default {
height: 44.82230388130942, height: 44.82230388130942,
seed: 683572113, seed: 683572113,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1199,7 +1161,6 @@ export default {
height: 5.896061363392446, height: 5.896061363392446,
seed: 318798801, seed: 318798801,
groupIds: ["TC0RSM64Cxmu17MlE12-o"], groupIds: ["TC0RSM64Cxmu17MlE12-o"],
frameId: null,
strokeSharpness: "round", strokeSharpness: "round",
boundElementIds: [], boundElementIds: [],
startBinding: null, startBinding: null,
@@ -1239,7 +1200,6 @@ export default {
height: 108.30428902193904, height: 108.30428902193904,
seed: 1914896753, seed: 1914896753,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1263,7 +1223,6 @@ export default {
height: 82.83278895375764, height: 82.83278895375764,
seed: 1306468145, seed: 1306468145,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1287,7 +1246,6 @@ export default {
height: 11.427824006438863, height: 11.427824006438863,
seed: 93422161, seed: 93422161,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1311,7 +1269,6 @@ export default {
height: 19.889460471185775, height: 19.889460471185775,
seed: 11646495, seed: 11646495,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -1335,7 +1292,6 @@ export default {
height: 19.889460471185775, height: 19.889460471185775,
seed: 291717649, seed: 291717649,
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"], groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
frameId: null,
strokeSharpness: "sharp", strokeSharpness: "sharp",
boundElementIds: [], boundElementIds: [],
}, },
@@ -44,8 +44,7 @@ module.exports = {
}, },
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude: /node_modules\/(?!browser-fs-access)/,
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
use: [ use: [
{ {
loader: "ts-loader", loader: "ts-loader",
@@ -46,9 +46,7 @@ module.exports = {
}, },
{ {
test: /\.(ts|tsx|js|jsx|mjs)$/, test: /\.(ts|tsx|js|jsx|mjs)$/,
exclude: exclude: /node_modules\/(?!browser-fs-access)/,
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
use: [ use: [
{ {
loader: "ts-loader", loader: "ts-loader",
+29 -134
View File
@@ -34,7 +34,6 @@ import { AppState, BinaryFiles, Zoom } from "../types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
SVG_NS, SVG_NS,
@@ -49,7 +48,6 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
// using a stronger invert (100% vs our regular 93%) and saturate // using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original // as a temp hack to make images in dark theme look closer to original
@@ -94,7 +92,6 @@ export interface ExcalidrawElementWithCanvas {
canvasOffsetX: number; canvasOffsetX: number;
canvasOffsetY: number; canvasOffsetY: number;
boundTextElementVersion: number | null; boundTextElementVersion: number | null;
containingFrameOpacity: number;
} }
const cappedElementCanvasSize = ( const cappedElementCanvasSize = (
@@ -210,7 +207,6 @@ const generateElementCanvas = (
canvasOffsetX, canvasOffsetX,
canvasOffsetY, canvasOffsetY,
boundTextElementVersion: getBoundTextElement(element)?.version || null, boundTextElementVersion: getBoundTextElement(element)?.version || null,
containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
}; };
}; };
@@ -257,8 +253,7 @@ const drawElementOnCanvas = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: RenderConfig, renderConfig: RenderConfig,
) => { ) => {
context.globalAlpha = context.globalAlpha = element.opacity / 100;
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "diamond": case "diamond":
@@ -474,7 +469,7 @@ const generateElementShape = (
elementWithCanvasCache.delete(element); elementWithCanvasCache.delete(element);
switch (element.type) { switch (element.type) {
case "rectangle": { case "rectangle":
if (element.roundness) { if (element.roundness) {
const w = element.width; const w = element.width;
const h = element.height; const h = element.height;
@@ -499,7 +494,6 @@ const generateElementShape = (
setShapeForElement(element, shape); setShapeForElement(element, shape);
break; break;
}
case "diamond": { case "diamond": {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element); getDiamondPoints(element);
@@ -723,14 +717,12 @@ const generateElementWithCanvas = (
prevElementWithCanvas.zoomValue !== zoom.value && prevElementWithCanvas.zoomValue !== zoom.value &&
!renderConfig?.shouldCacheIgnoreZoom; !renderConfig?.shouldCacheIgnoreZoom;
const boundTextElementVersion = getBoundTextElement(element)?.version || null; const boundTextElementVersion = getBoundTextElement(element)?.version || null;
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
if ( if (
!prevElementWithCanvas || !prevElementWithCanvas ||
shouldRegenerateBecauseZoom || shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== renderConfig.theme || prevElementWithCanvas.theme !== renderConfig.theme ||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
) { ) {
const elementWithCanvas = generateElementCanvas( const elementWithCanvas = generateElementCanvas(
element, element,
@@ -905,59 +897,25 @@ export const renderElement = (
const generator = rc.generator; const generator = rc.generator;
switch (element.type) { switch (element.type) {
case "selection": { case "selection": {
// do not render selection when exporting context.save();
if (!renderConfig.isExporting) { context.translate(
context.save(); element.x + renderConfig.scrollX,
context.translate( element.y + renderConfig.scrollY,
element.x + renderConfig.scrollX, );
element.y + renderConfig.scrollY, context.fillStyle = "rgba(0, 0, 200, 0.04)";
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
// render from 0.5px offset to get 1px wide line // render from 0.5px offset to get 1px wide line
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540 // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
// TODO can be be improved by offseting to the negative when user selects // TODO can be be improved by offseting to the negative when user selects
// from right to left // from right to left
const offset = 0.5 / renderConfig.zoom.value; const offset = 0.5 / renderConfig.zoom.value;
context.fillRect(offset, offset, element.width, element.height); context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / renderConfig.zoom.value; context.lineWidth = 1 / renderConfig.zoom.value;
context.strokeStyle = " rgb(105, 101, 219)"; context.strokeStyle = "rgb(105, 101, 219)";
context.strokeRect(offset, offset, element.width, element.height); context.strokeRect(offset, offset, element.width, element.height);
context.restore(); context.restore();
}
break;
}
case "frame": {
if (!renderConfig.isExporting && appState.shouldRenderFrames) {
context.save();
context.translate(
element.x + renderConfig.scrollX,
element.y + renderConfig.scrollY,
);
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = 2 / renderConfig.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
element.width,
element.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
context.stroke();
context.closePath();
} else {
context.strokeRect(0, 0, element.width, element.height);
}
context.restore();
}
break; break;
} }
case "freedraw": { case "freedraw": {
@@ -1149,23 +1107,6 @@ const roughSVGDrawWithPrecision = (
return rsvg.draw(pshape); return rsvg.draw(pshape);
}; };
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
exportedFrameId?: string | null,
) => {
const frame = getContainingFrame(element);
if (frame && frame.id === exportedFrameId) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
return g;
}
return null;
};
export const renderElementToSvg = ( export const renderElementToSvg = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
rsvg: RoughSVG, rsvg: RoughSVG,
@@ -1174,7 +1115,6 @@ export const renderElementToSvg = (
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
exportWithDarkMode?: boolean, exportWithDarkMode?: boolean,
exportingFrameId?: string | null,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
let cx = (x2 - x1) / 2 - (element.x - x1); let cx = (x2 - x1) / 2 - (element.x - x1);
@@ -1208,9 +1148,6 @@ export const renderElementToSvg = (
root = anchorTag; root = anchorTag;
} }
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "selection": { case "selection": {
// Since this is used only during editing experience, which is canvas based, // Since this is used only during editing experience, which is canvas based,
@@ -1226,6 +1163,7 @@ export const renderElementToSvg = (
getShapeForElement(element)!, getShapeForElement(element)!,
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
); );
const opacity = element.opacity / 100;
if (opacity !== 1) { if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`);
@@ -1237,15 +1175,7 @@ export const renderElementToSvg = (
offsetY || 0 offsetY || 0
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
break; break;
} }
case "line": case "line":
@@ -1298,6 +1228,7 @@ export const renderElementToSvg = (
if (boundText) { if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`); group.setAttribute("mask", `url(#mask-${element.id})`);
} }
const opacity = element.opacity / 100;
group.setAttribute("stroke-linecap", "round"); group.setAttribute("stroke-linecap", "round");
getShapeForElement(element)!.forEach((shape) => { getShapeForElement(element)!.forEach((shape) => {
@@ -1325,24 +1256,14 @@ export const renderElementToSvg = (
} }
group.appendChild(node); group.appendChild(node);
}); });
root.appendChild(group);
const g = maybeWrapNodesInFrameClipPath( root.append(maskPath);
element,
root,
[group, maskPath],
exportingFrameId,
);
if (g) {
root.appendChild(g);
} else {
root.appendChild(group);
root.append(maskPath);
}
break; break;
} }
case "freedraw": { case "freedraw": {
generateElementShape(element, generator); generateElementShape(element, generator);
generateFreeDrawShape(element); generateFreeDrawShape(element);
const opacity = element.opacity / 100;
const shape = getShapeForElement(element); const shape = getShapeForElement(element);
const node = shape const node = shape
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT) ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
@@ -1362,15 +1283,7 @@ export const renderElementToSvg = (
path.setAttribute("fill", element.strokeColor); path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element)); path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path); node.appendChild(path);
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
break; break;
} }
case "image": { case "image": {
@@ -1406,7 +1319,6 @@ export const renderElementToSvg = (
use.setAttribute("width", `${width}`); use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`); use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring) // We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation // on the <use> element, then apply translation and rotation
@@ -1432,22 +1344,13 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
const clipG = maybeWrapNodesInFrameClipPath( root.appendChild(g);
element,
root,
[g],
exportingFrameId,
);
clipG ? root.appendChild(clipG) : root.appendChild(g);
} }
break; break;
} }
// frames are not rendered and only acts as a container
case "frame": {
break;
}
default: { default: {
if (isTextElement(element)) { if (isTextElement(element)) {
const opacity = element.opacity / 100;
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) { if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("stroke-opacity", `${opacity}`);
@@ -1492,15 +1395,7 @@ export const renderElementToSvg = (
text.setAttribute("dominant-baseline", "text-before-edge"); text.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(text); node.appendChild(text);
} }
root.appendChild(node);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
exportingFrameId,
);
g ? root.appendChild(g) : root.appendChild(node);
} else { } else {
// @ts-ignore // @ts-ignore
throw new Error(`Unimplemented type ${element.type}`); throw new Error(`Unimplemented type ${element.type}`);
+69 -261
View File
@@ -10,7 +10,6 @@ import {
NonDeleted, NonDeleted,
GroupId, GroupId,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@@ -31,13 +30,12 @@ import {
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { renderElement, renderElementToSvg } from "./renderElement"; import { renderElement, renderElementToSvg } from "./renderElement";
import { getClientColor } from "../clients"; import { getClientColors } from "../clients";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
isSelectedViaGroup, isSelectedViaGroup,
getSelectedGroupIds, getSelectedGroupIds,
getElementsInGroup, getElementsInGroup,
selectGroupsFromGivenElements,
} from "../groups"; } from "../groups";
import { maxBindingGap } from "../element/collision"; import { maxBindingGap } from "../element/collision";
import { import {
@@ -46,30 +44,24 @@ import {
isBindingEnabled, isBindingEnabled,
} from "../element/binding"; } from "../element/binding";
import { import {
OMIT_SIDES_FOR_FRAME,
shouldShowBoundingBox, shouldShowBoundingBox,
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { import {
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
supportsEmoji,
throttleRAF, throttleRAF,
isOnlyExportingSingleFrame,
} from "../utils"; } from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { FRAME_STYLE, THEME_FILTER } from "../constants"; import { THEME_FILTER } from "../constants";
import { import {
EXTERNAL_LINK_IMG, EXTERNAL_LINK_IMG,
getLinkHandleFromCoords, getLinkHandleFromCoords,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { isFrameElement, isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getTargetFrame,
isElementInFrame,
} from "../frame";
import "canvas-roundrect-polyfill";
const hasEmojiSupport = supportsEmoji();
export const DEFAULT_SPACING = 2; export const DEFAULT_SPACING = 2;
const strokeRectWithRotation = ( const strokeRectWithRotation = (
@@ -82,8 +74,6 @@ const strokeRectWithRotation = (
cy: number, cy: number,
angle: number, angle: number,
fill: boolean = false, fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => { ) => {
context.save(); context.save();
context.translate(cx, cy); context.translate(cx, cy);
@@ -91,14 +81,7 @@ const strokeRectWithRotation = (
if (fill) { if (fill) {
context.fillRect(x - cx, y - cy, width, height); context.fillRect(x - cx, y - cy, width, height);
} }
if (radius && context.roundRect) { context.strokeRect(x - cx, y - cy, width, height);
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore(); context.restore();
}; };
@@ -176,6 +159,7 @@ const strokeGrid = (
const renderSingleLinearPoint = ( const renderSingleLinearPoint = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig, renderConfig: RenderConfig,
point: Point, point: Point,
radius: number, radius: number,
@@ -222,7 +206,14 @@ const renderLinearPointHandles = (
const isSelected = const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint(context, renderConfig, point, radius, isSelected); renderSingleLinearPoint(
context,
appState,
renderConfig,
point,
radius,
isSelected,
);
}); });
//Rendering segment mid points //Rendering segment mid points
@@ -246,6 +237,7 @@ const renderLinearPointHandles = (
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
radius, radius,
@@ -256,6 +248,7 @@ const renderLinearPointHandles = (
highlightPoint(segmentMidPoint, context, renderConfig); highlightPoint(segmentMidPoint, context, renderConfig);
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
radius, radius,
@@ -265,6 +258,7 @@ const renderLinearPointHandles = (
} else if (appState.editingLinearElement || points.length === 2) { } else if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
@@ -320,34 +314,6 @@ const renderLinearElementPointHighlight = (
context.restore(); context.restore();
}; };
const frameClip = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
) => {
context.translate(
frame.x + renderConfig.scrollX,
frame.y + renderConfig.scrollY,
);
context.beginPath();
if (context.roundRect && !renderConfig.isExporting) {
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
} else {
context.rect(0, 0, frame.width, frame.height);
}
context.clip();
context.translate(
-(frame.x + renderConfig.scrollX),
-(frame.y + renderConfig.scrollY),
);
};
export const _renderScene = ({ export const _renderScene = ({
elements, elements,
appState, appState,
@@ -439,51 +405,11 @@ export const _renderScene = ({
}), }),
); );
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
element.groupIds.find((groupId) =>
groupsToBeAddedToFrame.has(groupId),
))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
);
}
});
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined = let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
undefined; undefined;
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
try { try {
// - when exporting the whole canvas, we DO NOT apply clipping renderElement(element, rc, context, renderConfig, appState);
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
(!renderConfig.isExporting && appState.shouldRenderFrames))
) {
context.save();
const frame = getTargetFrame(element, appState);
if (frame && isElementInFrame(element, elements, appState)) {
frameClip(frame, context, renderConfig);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
} else {
renderElement(element, rc, context, renderConfig, appState);
}
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the // ShapeCache returns empty hence making sure that we get the
// correct element from visible elements // correct element from visible elements
@@ -532,24 +458,7 @@ export const _renderScene = ({
renderBindingHighlight(context, renderConfig, suggestedBinding!); renderBindingHighlight(context, renderConfig, suggestedBinding!);
}); });
} }
if (appState.frameToHighlight) {
renderFrameHighlight(context, renderConfig, appState.frameToHighlight);
}
if (appState.elementsToHighlight) {
renderElementsBoxHighlight(
context,
renderConfig,
appState.elementsToHighlight,
appState,
);
}
const locallySelectedElements = getSelectedElements(elements, appState); const locallySelectedElements = getSelectedElements(elements, appState);
const isFrameSelected = locallySelectedElements.some((element) =>
isFrameElement(element),
);
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the // ShapeCache returns empty hence making sure that we get the
@@ -618,7 +527,7 @@ export const _renderScene = ({
selectionColors.push( selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map( ...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => { (socketId) => {
const background = getClientColor(socketId); const { background } = getClientColors(socketId, appState);
return background; return background;
}, },
), ),
@@ -719,9 +628,7 @@ export const _renderScene = ({
0, 0,
renderConfig.zoom, renderConfig.zoom,
"mouse", "mouse",
isFrameSelected OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
? OMIT_SIDES_FOR_FRAME
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
); );
if (locallySelectedElements.some((element) => !element.locked)) { if (locallySelectedElements.some((element) => !element.locked)) {
renderTransformHandles(context, renderConfig, transformHandles, 0); renderTransformHandles(context, renderConfig, transformHandles, 0);
@@ -740,7 +647,7 @@ export const _renderScene = ({
x -= appState.offsetLeft; x -= appState.offsetLeft;
y -= appState.offsetTop; y -= appState.offsetTop;
const width = 11; const width = 9;
const height = 14; const height = 14;
const isOutOfBounds = const isOutOfBounds =
@@ -754,20 +661,15 @@ export const _renderScene = ({
y = Math.max(y, 0); y = Math.max(y, 0);
y = Math.min(y, normalizedCanvasHeight - height); y = Math.min(y, normalizedCanvasHeight - height);
const background = getClientColor(clientId); const { background, stroke } = getClientColors(clientId, appState);
context.save(); context.save();
context.strokeStyle = background; context.strokeStyle = stroke;
context.fillStyle = background; context.fillStyle = background;
const userState = renderConfig.remotePointerUserStates[clientId]; const userState = renderConfig.remotePointerUserStates[clientId];
const isInactive = if (isOutOfBounds || userState === UserIdleState.AWAY) {
isOutOfBounds || context.globalAlpha = 0.48;
userState === UserIdleState.IDLE ||
userState === UserIdleState.AWAY;
if (isInactive) {
context.globalAlpha = 0.3;
} }
if ( if (
@@ -784,86 +686,73 @@ export const _renderScene = ({
context.beginPath(); context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false); context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 1; context.lineWidth = 1;
context.strokeStyle = background; context.strokeStyle = stroke;
context.stroke(); context.stroke();
context.closePath(); context.closePath();
} }
// Background (white outline) for arrow
context.fillStyle = oc.white;
context.strokeStyle = oc.white;
context.lineWidth = 6;
context.lineJoin = "round";
context.beginPath(); context.beginPath();
context.moveTo(x, y); context.moveTo(x, y);
context.lineTo(x + 0, y + 14); context.lineTo(x + 1, y + 14);
context.lineTo(x + 4, y + 9); context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8); context.lineTo(x + 9, y + 10);
context.closePath(); context.lineTo(x, y);
context.stroke();
context.fill(); context.fill();
context.stroke();
// Arrow const username = renderConfig.remotePointerUsernames[clientId];
context.fillStyle = background;
context.strokeStyle = background; let idleState = "";
context.lineWidth = 2; if (userState === UserIdleState.AWAY) {
context.lineJoin = "round"; idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
context.beginPath(); } else if (userState === UserIdleState.IDLE) {
if (isInactive) { idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
context.moveTo(x - 1, y - 1);
context.lineTo(x - 1, y + 15);
context.lineTo(x + 5, y + 10);
context.lineTo(x + 12, y + 9);
context.closePath();
context.fill();
} else {
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.fill();
context.stroke();
} }
const username = renderConfig.remotePointerUsernames[clientId] || ""; const usernameAndIdleState = `${username || ""}${
idleState ? ` ${idleState}` : ""
}`;
if (!isOutOfBounds && username) { if (!isOutOfBounds && usernameAndIdleState) {
context.font = "600 12px sans-serif"; // font has to be set before context.measureText() const offsetX = x + width;
const offsetY = y + height;
const offsetX = x + width / 2; const paddingHorizontal = 4;
const offsetY = y + height + 2; const paddingVertical = 4;
const paddingHorizontal = 5; const measure = context.measureText(usernameAndIdleState);
const paddingVertical = 3;
const measure = context.measureText(username);
const measureHeight = const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
const finalHeight = Math.max(measureHeight, 12);
const boxX = offsetX - 1; const boxX = offsetX - 1;
const boxY = offsetY - 1; const boxY = offsetY - 1;
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; const boxWidth = measure.width + 2 * paddingHorizontal + 2;
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; const boxHeight = measureHeight + 2 * paddingVertical + 2;
if (context.roundRect) { if (context.roundRect) {
context.beginPath(); context.beginPath();
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); context.roundRect(
boxX,
boxY,
boxWidth,
boxHeight,
4 / renderConfig.zoom.value,
);
context.fillStyle = background; context.fillStyle = background;
context.fill(); context.fill();
context.strokeStyle = oc.white; context.fillStyle = stroke;
context.stroke(); context.stroke();
} else { } else {
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white); // Border
context.fillStyle = stroke;
context.fillRect(boxX, boxY, boxWidth, boxHeight);
// Background
context.fillStyle = background;
context.fillRect(offsetX, offsetY, boxWidth - 2, boxHeight - 2);
} }
context.fillStyle = oc.black; context.fillStyle = oc.white;
context.fillText( context.fillText(
username, usernameAndIdleState,
offsetX + paddingHorizontal + 1, offsetX + paddingHorizontal,
offsetY + offsetY + paddingVertical + measure.actualBoundingBoxAscent,
paddingVertical +
measure.actualBoundingBoxAscent +
Math.floor((finalHeight - measureHeight) / 2) +
2,
); );
} }
@@ -1082,7 +971,6 @@ const renderBindingHighlightForBindableElement = (
case "rectangle": case "rectangle":
case "text": case "text":
case "image": case "image":
case "frame":
strokeRectWithRotation( strokeRectWithRotation(
context, context,
x1 - padding, x1 - padding,
@@ -1120,82 +1008,6 @@ const renderBindingHighlightForBindableElement = (
} }
}; };
const renderFrameHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
frame: NonDeleted<ExcalidrawFrameElement>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgb(0,118,255)";
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / renderConfig.zoom.value;
context.save();
context.translate(renderConfig.scrollX, renderConfig.scrollY);
strokeRectWithRotation(
context,
x1,
y1,
width,
height,
x1 + width / 2,
y1 + height / 2,
frame.angle,
false,
FRAME_STYLE.radius / renderConfig.zoom.value,
);
context.restore();
};
const renderElementsBoxHighlight = (
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
elements: NonDeleted<ExcalidrawElement>[],
appState: AppState,
) => {
const individualElements = elements.filter(
(element) => element.groupIds.length === 0,
);
const elementsInGroups = elements.filter(
(element) => element.groupIds.length > 0,
);
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(elements);
return {
angle: 0,
elementX1,
elementX2,
elementY1,
elementY2,
selectionColors: ["rgb(0,118,255)"],
dashed: false,
cx: elementX1 + (elementX2 - elementX1) / 2,
cy: elementY1 + (elementY2 - elementY1) / 2,
};
};
const getSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elements, groupId);
return getSelectionFromElements(groupElements);
};
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
.filter(([id, isSelected]) => isSelected)
.map(([id, isSelected]) => id)
.map((groupId) => getSelectionForGroupId(groupId))
.concat(
individualElements.map((element) => getSelectionFromElements([element])),
)
.forEach((selection) =>
renderSelectionBorder(context, renderConfig, selection),
);
};
const renderBindingHighlightForSuggestedPointBinding = ( const renderBindingHighlightForSuggestedPointBinding = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
suggestedBinding: SuggestedPointBinding, suggestedBinding: SuggestedPointBinding,
@@ -1277,7 +1089,7 @@ const renderLinkIcon = (
} }
}; };
export const isVisibleElement = ( const isVisibleElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
canvasWidth: number, canvasWidth: number,
canvasHeight: number, canvasHeight: number,
@@ -1323,20 +1135,17 @@ export const renderSceneToSvg = (
offsetX = 0, offsetX = 0,
offsetY = 0, offsetY = 0,
exportWithDarkMode = false, exportWithDarkMode = false,
exportingFrameId = null,
}: { }: {
offsetX?: number; offsetX?: number;
offsetY?: number; offsetY?: number;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
exportingFrameId?: string | null;
} = {}, } = {},
) => { ) => {
if (!svgRoot) { if (!svgRoot) {
return; return;
} }
// render elements // render elements
elements.forEach((element) => { elements.forEach((element, index) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
renderElementToSvg( renderElementToSvg(
@@ -1347,7 +1156,6 @@ export const renderSceneToSvg = (
element.x + offsetX, element.x + offsetX,
element.y + offsetY, element.y + offsetY,
exportWithDarkMode, exportWithDarkMode,
exportingFrameId,
); );
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
-4
View File
@@ -15,7 +15,6 @@ export const roundRect = (
width: number, width: number,
height: number, height: number,
radius: number, radius: number,
strokeColor?: string,
) => { ) => {
context.beginPath(); context.beginPath();
context.moveTo(x + radius, y); context.moveTo(x + radius, y);
@@ -34,8 +33,5 @@ export const roundRect = (
context.quadraticCurveTo(x, y, x + radius, y); context.quadraticCurveTo(x, y, x + radius, y);
context.closePath(); context.closePath();
context.fill(); context.fill();
if (strokeColor) {
context.strokeStyle = strokeColor;
}
context.stroke(); context.stroke();
}; };
+1 -51
View File
@@ -2,15 +2,9 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import { getNonDeletedElements, isNonDeletedElement } from "../element";
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -18,10 +12,6 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") { if (typeof elementKey === "string") {
return true; return true;
@@ -65,8 +55,6 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
@@ -77,14 +65,6 @@ class Scene {
return this.nonDeletedElements; return this.nonDeletedElements;
} }
getFramesIncludingDeleted() {
return this.frames;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null { getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null; return (this.elementsMap.get(id) as T | undefined) || null;
} }
@@ -130,19 +110,12 @@ class Scene {
replaceAllElements(nextElements: readonly ExcalidrawElement[]) { replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
this.elements = nextElements; this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();
nextElements.forEach((element) => { nextElements.forEach((element) => {
if (isFrameElement(element)) {
nextFrames.push(element);
}
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this); Scene.mapElementToScene(element, this);
}); });
this.nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames;
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
this.informMutation(); this.informMutation();
} }
@@ -192,29 +165,6 @@ class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
this.replaceAllElements(nextElements);
}
addNewElement = (element: ExcalidrawElement) => {
if (element.frameId) {
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
} else {
this.replaceAllElements([...this.elements, element]);
}
};
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId); return this.elements.findIndex((element) => element.id === elementId);
} }
+1 -2
View File
@@ -7,8 +7,7 @@ export const hasBackground = (type: string) =>
type === "line" || type === "line" ||
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: string) => export const hasStrokeColor = (type: string) => type !== "image";
type !== "image" && type !== "frame";
export const hasStrokeWidth = (type: string) => export const hasStrokeWidth = (type: string) =>
type === "rectangle" || type === "rectangle" ||
+8 -75
View File
@@ -1,8 +1,8 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { renderScene, renderSceneToSvg } from "../renderer/renderScene"; import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils"; import { distance } from "../utils";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants"; import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
@@ -11,7 +11,6 @@ import {
getInitializedImageElements, getInitializedImageElements,
updateImageCache, updateImageCache,
} from "../element/image"; } from "../element/image";
import Scene from "./Scene";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -52,8 +51,6 @@ export const exportToCanvas = async (
files, files,
}); });
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
renderScene({ renderScene({
elements, elements,
appState, appState,
@@ -62,8 +59,8 @@ export const exportToCanvas = async (
canvas, canvas,
renderConfig: { renderConfig: {
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding), scrollX: -minX + exportPadding,
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding), scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
remotePointerViewportCoords: {}, remotePointerViewportCoords: {},
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
@@ -91,7 +88,6 @@ export const exportToSvg = async (
viewBackgroundColor: string; viewBackgroundColor: string;
exportWithDarkMode?: boolean; exportWithDarkMode?: boolean;
exportEmbedScene?: boolean; exportEmbedScene?: boolean;
renderFrame?: boolean;
}, },
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: { opts?: {
@@ -144,39 +140,6 @@ export const exportToSvg = async (
} }
assetPath = `${assetPath}/dist/excalidraw-assets/`; assetPath = `${assetPath}/dist/excalidraw-assets/`;
} }
// do not apply clipping when we're exporting the whole scene
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
const exportingFrame =
isExportingWholeCanvas || !onlyExportingSingleFrame
? undefined
: elements.find((element) => element.type === "frame");
let exportingFrameClipPath = "";
if (exportingFrame) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
<rect transform="translate(${exportingFrame.x + offsetX} ${
exportingFrame.y + offsetY
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
width="${exportingFrame.width}"
height="${exportingFrame.height}"
>
</rect>
</clipPath>`;
}
svgRoot.innerHTML = ` svgRoot.innerHTML = `
${SVG_EXPORT_TAG} ${SVG_EXPORT_TAG}
${metadata} ${metadata}
@@ -191,10 +154,8 @@ export const exportToSvg = async (
src: url("${assetPath}Cascadia.woff2"); src: url("${assetPath}Cascadia.woff2");
} }
</style> </style>
${exportingFrameClipPath}
</defs> </defs>
`; `;
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { if (appState.exportBackground && viewBackgroundColor) {
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
@@ -208,10 +169,9 @@ export const exportToSvg = async (
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, { renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
offsetX, offsetX: -minX + exportPadding,
offsetY, offsetY: -minY + exportPadding,
exportWithDarkMode: appState.exportWithDarkMode, exportWithDarkMode: appState.exportWithDarkMode,
exportingFrameId: exportingFrame?.id || null,
}); });
return svgRoot; return svgRoot;
@@ -222,36 +182,9 @@ const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): [number, number, number, number] => { ): [number, number, number, number] => {
// we should decide if we are exporting the whole canvas
// if so, we are not clipping elements in the frame
// and therefore, we should not do anything special
const isExportingWholeCanvas =
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
elements.length;
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
const frames = elements.filter((element) => element.type === "frame");
const exportedFrameIds = frames.reduce((acc, frame) => {
acc[frame.id] = true;
return acc;
}, {} as Record<string, true>);
// elements in a frame do not affect the canvas size if we're not exporting
// the whole canvas
elements = elements.filter(
(element) => !exportedFrameIds[element.frameId ?? ""],
);
}
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const width = const width = distance(minX, maxX) + exportPadding * 2;
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2); const height = distance(minY, maxY) + exportPadding + exportPadding;
const height =
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
return [minX, minY, width, height]; return [minX, minY, width, height];
}; };
+7 -89
View File
@@ -5,61 +5,17 @@ import {
import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameElements,
} from "../frame";
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
* we only keep the frames.
* @param selectedElements
*/
export const excludeElementsInFramesFromSelection = <
T extends ExcalidrawElement,
>(
selectedElements: readonly T[],
) => {
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (element.type === "frame") {
framesInSelection.add(element.id);
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
excludeElementsInFrames: boolean = true,
) => { ) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection); getElementAbsoluteCoords(selection);
return elements.filter((element) => {
let elementsInSelection = elements.filter((element) => { const [elementX1, elementY1, elementX2, elementY2] =
let [elementX1, elementY1, elementX2, elementY2] =
getElementBounds(element); getElementBounds(element);
const containingFrame = getContainingFrame(element);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return ( return (
element.locked === false && element.locked === false &&
element.type !== "selection" && element.type !== "selection" &&
@@ -70,22 +26,6 @@ export const getElementsWithinSelection = (
selectionY2 >= elementY2 selectionY2 >= elementY2
); );
}); });
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame);
}
return true;
});
return elementsInSelection;
}; };
export const isSomeElementSelected = ( export const isSomeElementSelected = (
@@ -116,17 +56,14 @@ export const getCommonAttributeOfSelectedElements = <T>(
export const getSelectedElements = ( export const getSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">, appState: Pick<AppState, "selectedElementIds">,
opts?: { includeBoundTextElement: boolean = false,
includeBoundTextElement?: boolean; ) =>
includeElementsInFrames?: boolean; elements.filter((element) => {
},
) => {
const selectedElements = elements.filter((element) => {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
return element; return element;
} }
if ( if (
opts?.includeBoundTextElement && includeBoundTextElement &&
isBoundToContainer(element) && isBoundToContainer(element) &&
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
@@ -135,29 +72,10 @@ export const getSelectedElements = (
return null; return null;
}); });
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (element.type === "frame") {
getFrameElements(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);
}
elementsToInclude.push(element);
});
return elementsToInclude;
}
return selectedElements;
};
export const getTargetElements = ( export const getTargetElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds" | "editingElement">, appState: Pick<AppState, "selectedElementIds" | "editingElement">,
) => ) =>
appState.editingElement appState.editingElement
? [appState.editingElement] ? [appState.editingElement]
: getSelectedElements(elements, appState, { : getSelectedElements(elements, appState, true);
includeBoundTextElement: true,
});
-8
View File
@@ -83,14 +83,6 @@ export const SHAPES = [
numericKey: KEYS["0"], numericKey: KEYS["0"],
fillable: false, fillable: false,
}, },
// TODO: frame, create icon and set up numeric key
// {
// icon: RectangleIcon,
// value: "frame",
// key: KEYS.F,
// numericKey: KEYS.SUBTRACT,
// fillable: false,
// },
] as const; ] as const;
export const findShapeByKey = (key: string) => { export const findShapeByKey = (key: string) => {
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,6 @@ Object {
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -57,7 +56,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -91,7 +89,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -125,7 +122,6 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -172,7 +168,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
+1 -2
View File
@@ -15,7 +15,6 @@ exports[`export exporting svg containing transformed images: svg export output 1
src: url(\\"https://excalidraw.com/Cascadia.woff2\\"); src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
} }
</style> </style>
</defs> </defs>
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>" <g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
`; `;
@@ -6,7 +6,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0_copy", "id": "id0_copy",
@@ -38,7 +37,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -70,7 +68,6 @@ Object {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
@@ -107,7 +104,6 @@ Object {
}, },
], ],
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 100, "height": 100,
"id": "id0", "id": "id0",
@@ -144,7 +140,6 @@ Object {
}, },
], ],
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 300, "height": 300,
"id": "id1", "id": "id1",
@@ -182,7 +177,6 @@ Object {
"gap": 10, "gap": 10,
}, },
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 81.48231043525051, "height": 81.48231043525051,
"id": "id2", "id": "id2",
@@ -8,7 +8,6 @@ Object {
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
"id": "id0", "id": "id0",
@@ -62,7 +61,6 @@ Object {
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"frameId": null,
"groupIds": Array [], "groupIds": Array [],
"height": 110, "height": 110,
"id": "id0", "id": "id0",

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