mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
25 Commits
zsviczian-
...
feat-custo
Author | SHA1 | Date | |
---|---|---|---|
5e828da559 | |||
8336edb4a0 | |||
81ebf82979 | |||
4d7d96eb7b | |||
1747e93957 | |||
3bd5d87cac | |||
74d2fc6406 | |||
ce9acfbc55 | |||
16c7945ca0 | |||
5ca3613cc3 | |||
b4abfad638 | |||
a39640ead1 | |||
84bd9bd4ff | |||
ae7ff76126 | |||
27e2888347 | |||
e192538267 | |||
1d3652a96c | |||
bb96f322c6 | |||
e6fb7e3016 | |||
e385066b4b | |||
a5bd54b86d | |||
01432813a6 | |||
6e3b575fa5 | |||
333cc53797 | |||
8e5d376b49 |
@ -19,6 +19,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
@ -27,6 +28,7 @@
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
@ -106,7 +108,7 @@
|
||||
"<rootDir>/src/packages/excalidraw/example"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
|
@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
@ -17,10 +18,20 @@ import { AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
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 = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -36,14 +47,16 @@ const alignSelectedElements = (
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -58,7 +71,7 @@ export const actionAlignTop = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -74,6 +87,7 @@ export const actionAlignTop = register({
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -149,7 +164,7 @@ export const actionAlignRight = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -165,7 +180,7 @@ export const actionAlignRight = register({
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
|
||||
"rectangle",
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
frameId: textElement.frameId,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
|
@ -20,6 +20,8 @@ import {
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { Bounds } from "../element/bounds";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@ -206,7 +208,7 @@ export const actionResetZoom = register({
|
||||
});
|
||||
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: [number, number, number, number],
|
||||
bounds: Bounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
@ -234,8 +236,10 @@ export const zoomToFitElements = (
|
||||
|
||||
const commonBounds =
|
||||
zoomToSelection && selectedElements.length > 0
|
||||
? getCommonBounds(selectedElements)
|
||||
: getCommonBounds(nonDeletedElements);
|
||||
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
|
||||
: getCommonBounds(
|
||||
excludeElementsInFramesFromSelection(nonDeletedElements),
|
||||
);
|
||||
|
||||
const newZoom = {
|
||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
|
@ -16,9 +16,12 @@ export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const elementsToCopy = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
copyToClipboard(selectedElements, app.files);
|
||||
copyToClipboard(elementsToCopy, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
@ -172,7 +181,9 @@ export const copyText = register({
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
@ -191,7 +202,9 @@ export const copyText = register({
|
||||
predicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
@ -18,11 +18,23 @@ const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => el.type === "frame"),
|
||||
appState,
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
|
@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
@ -16,7 +17,17 @@ import { register } from "./register";
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
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 = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -32,8 +43,9 @@ const distributeSelectedElements = (
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
@ -20,9 +20,17 @@ import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
@ -94,8 +102,11 @@ const duplicateElements = (
|
||||
return newElement;
|
||||
};
|
||||
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, true),
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
@ -129,12 +140,25 @@ const duplicateElements = (
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (selectedElementIds.get(element.id)) {
|
||||
// if a group or a container/bound-text, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement) {
|
||||
const isElementAFrame = isFrameElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrame) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(sortedElements, groupId);
|
||||
// TODO:
|
||||
// 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(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
@ -156,10 +180,34 @@ const duplicateElements = (
|
||||
);
|
||||
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 {
|
||||
elementsWithClones.push(...markAsProcessed([element]));
|
||||
}
|
||||
@ -200,6 +248,14 @@ const duplicateElements = (
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
@ -207,7 +263,7 @@ const duplicateElements = (
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce(
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
|
@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
const selected = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
}).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -12,13 +12,17 @@ import {
|
||||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
@ -50,6 +57,9 @@ const flipSelectedElements = (
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedElements = flipElements(
|
||||
|
140
src/actions/actionFrame.ts
Normal file
140
src/actions/actionFrame.ts
Normal file
@ -0,0 +1,140 @@
|
||||
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,
|
||||
});
|
@ -17,9 +17,19 @@ import {
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
groupByFrames,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
@ -45,7 +55,9 @@ const enableActionGroup = (
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
@ -55,11 +67,13 @@ const enableActionGroup = (
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
@ -86,9 +100,31 @@ export const actionGroup = register({
|
||||
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 selectElementIds = arrayToMap(selectedElements);
|
||||
const updatedElements = elements.map((element) => {
|
||||
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
return element;
|
||||
}
|
||||
@ -102,17 +138,16 @@ export const actionGroup = register({
|
||||
});
|
||||
// keep the z order within the group the same, but move them
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex =
|
||||
updatedElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
const updatedElementsInOrder = [
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
@ -122,9 +157,9 @@ export const actionGroup = register({
|
||||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(updatedElementsInOrder),
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: updatedElementsInOrder,
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
@ -148,14 +183,23 @@ export const actionGroup = register({
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
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 nextElements = elements.map((element) => {
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
boundTextElementIds.push(element.id);
|
||||
}
|
||||
@ -176,13 +220,23 @@ export const actionUngroup = register({
|
||||
getNonDeletedElements(nextElements),
|
||||
);
|
||||
|
||||
frames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// remove binded text elements from selection
|
||||
boundTextElementIds.forEach(
|
||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||
);
|
||||
return {
|
||||
appState: updateAppState,
|
||||
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
|
@ -67,7 +67,6 @@ export const actionFullScreen = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getClientColors } from "../clients";
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const background = getClientColor(clientId);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
|
@ -102,8 +102,11 @@ const changeProperty = (
|
||||
includeBoundText = false,
|
||||
) => {
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(elements, appState, includeBoundText),
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: includeBoundText,
|
||||
}),
|
||||
);
|
||||
|
||||
return elements.map((element) => {
|
||||
if (
|
||||
selectedElementIds.get(element.id) ||
|
||||
|
@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
@ -13,19 +14,18 @@ export const actionSelectAll = register({
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const selectedElementIds = elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
|
||||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
@ -127,6 +130,13 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
}
|
||||
|
||||
if (isFrameElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
|
@ -2,10 +2,10 @@ import React from "react";
|
||||
import {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
ActionPredicateFn,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
@ -40,7 +40,8 @@ const trackAction = (
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
actions = {} as Record<Action["name"], Action>;
|
||||
actionPredicates = [] as ActionPredicateFn[];
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@ -68,6 +69,12 @@ export class ActionManager {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
registerActionPredicate(predicate: ActionPredicateFn) {
|
||||
if (!this.actionPredicates.includes(predicate)) {
|
||||
this.actionPredicates.push(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
registerAction(action: Action) {
|
||||
this.actions[action.name] = action;
|
||||
}
|
||||
@ -84,7 +91,7 @@ export class ActionManager {
|
||||
(action) =>
|
||||
(action.name in canvasActions
|
||||
? canvasActions[action.name as keyof typeof canvasActions]
|
||||
: true) &&
|
||||
: this.isActionEnabled(action, { noPredicates: true })) &&
|
||||
action.keyTest &&
|
||||
action.keyTest(
|
||||
event,
|
||||
@ -134,7 +141,7 @@ export class ActionManager {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@ -142,7 +149,7 @@ export class ActionManager {
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
(name in canvasActions
|
||||
? canvasActions[name as keyof typeof canvasActions]
|
||||
: true)
|
||||
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
@ -176,13 +183,31 @@ export class ActionManager {
|
||||
return null;
|
||||
};
|
||||
|
||||
isActionEnabled = (action: Action) => {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
isActionEnabled = (
|
||||
action: Action,
|
||||
opts?: {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
data?: Record<string, any>;
|
||||
noPredicates?: boolean;
|
||||
},
|
||||
): boolean => {
|
||||
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const data = opts?.data;
|
||||
|
||||
return (
|
||||
!action.predicate ||
|
||||
action.predicate(elements, appState, this.app.props, this.app)
|
||||
);
|
||||
if (
|
||||
!opts?.noPredicates &&
|
||||
action.predicate &&
|
||||
!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;
|
||||
};
|
||||
}
|
||||
|
@ -32,6 +32,15 @@ type ActionFn = (
|
||||
app: AppClassProperties,
|
||||
) => 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 ActionFilterFn = (action: Action) => void;
|
||||
|
||||
@ -116,6 +125,11 @@ export type ActionName =
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "toggleFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
@ -127,7 +141,7 @@ export type PanelComponentProps = {
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
name: string;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
@ -147,6 +161,7 @@ export interface Action {
|
||||
appState: AppState,
|
||||
appProps: ExcalidrawProps,
|
||||
app: AppClassProperties,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
@ -33,7 +33,7 @@ export const alignElements = (
|
||||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
selectionBoundingBox: Box,
|
||||
selectionBoundingBox: BoundingBox,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
|
@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
|
||||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
shouldRenderFrames: true,
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
elementsToHighlight: null,
|
||||
toast: null,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
zenModeEnabled: false,
|
||||
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { 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 },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { 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 },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
|
@ -1,31 +1,31 @@
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import { AppState } from "./types";
|
||||
|
||||
const BG_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
);
|
||||
const STROKE_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
);
|
||||
|
||||
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||
if (appState?.collaborators) {
|
||||
const currentUser = appState.collaborators.get(clientId);
|
||||
if (currentUser?.color) {
|
||||
return currentUser.color;
|
||||
}
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
return {
|
||||
background: BG_COLORS[sum % BG_COLORS.length],
|
||||
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
|
||||
};
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
) => {
|
||||
// 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}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@ -57,6 +60,9 @@ export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
);
|
||||
let foundFile = false;
|
||||
|
||||
const _files = elements.reduce((acc, element) => {
|
||||
@ -78,7 +84,20 @@ export const copyToClipboard = async (
|
||||
// select binded text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
frameId: null,
|
||||
});
|
||||
return copiedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
@ -35,6 +35,9 @@ import {
|
||||
} from "../element/textElement";
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
|
||||
<div>
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${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
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
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 = ({
|
||||
renderAction,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -10,10 +10,9 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: $oc-white;
|
||||
cursor: pointer;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
|
||||
&-img {
|
||||
|
@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
|
||||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isTransparent, isWritableElement } from "../../utils";
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
@ -121,11 +121,14 @@ const ColorPickerPopupContent = ({
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container
|
||||
if (container) {
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
|
@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
|
@ -84,7 +84,10 @@ const ImageExportModal = ({
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, true)
|
||||
? getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
})
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -204,12 +204,7 @@ const LayerUI = ({
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={6}
|
||||
className={clsx("App-menu_top__left", {
|
||||
"disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</Stack.Col>
|
||||
@ -254,7 +249,7 @@ const LayerUI = ({
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider"></div>
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
|
@ -148,7 +148,11 @@ const usePendingElementsMemo = (
|
||||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const create = () => getSelectedElements(elements, appState, true);
|
||||
const create = () =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
const val = useRef(create());
|
||||
const prevAppState = useRef<UIAppState>(appState);
|
||||
const prevElements = useRef(elements);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
style={props.style}
|
||||
data-testid={props["data-testid"]}
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
|
@ -15,7 +15,24 @@
|
||||
height: 1.5rem;
|
||||
align-self: center;
|
||||
background-color: var(--default-border-color);
|
||||
margin: 0 0.5rem;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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 { useUIAppState } from "../../context/ui-appState";
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
children,
|
||||
onToggle,
|
||||
title,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useUIAppState();
|
||||
title?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
@ -28,6 +28,8 @@ const MenuTrigger = ({
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
data-testid="dropdown-menu-button"
|
||||
title={title}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
@ -1616,3 +1616,24 @@ export const eyeDropperIcon = createIcon(
|
||||
</g>,
|
||||
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,
|
||||
);
|
||||
|
@ -42,6 +42,7 @@ const MainMenu = Object.assign(
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
|
@ -94,6 +94,17 @@ export const THEME = {
|
||||
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 DEFAULT_FONT_SIZE = 20;
|
||||
|
@ -62,6 +62,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
freedraw: true,
|
||||
eraser: false,
|
||||
custom: true,
|
||||
frame: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
@ -125,6 +126,7 @@ const restoreElementWithProperties = <
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
groupIds: element.groupIds ?? [],
|
||||
frameId: element.frameId ?? null,
|
||||
roundness: element.roundness
|
||||
? element.roundness
|
||||
: element.strokeSharpness === "round"
|
||||
@ -272,6 +274,10 @@ const restoreElement = (
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
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.
|
||||
// We also don't want to throw, but instead return void so we filter
|
||||
|
@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
|
||||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
|
||||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
|
||||
if (
|
||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
||||
|
@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
|
||||
];
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
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
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
@ -69,6 +160,111 @@ 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 = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
|
||||
return coords;
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
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 getElementBounds = (element: ExcalidrawElement): Bounds => {
|
||||
return ElementBounds.getBounds(element);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
|
||||
export const getClosestElementBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
from: { x: number; y: number },
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
|
||||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
@ -642,7 +786,7 @@ export interface Box {
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
minX,
|
||||
|
@ -26,10 +26,16 @@ import {
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
StrokeRoundness,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCurvePathOps,
|
||||
getRectangleBoxAbsoluteCoords,
|
||||
RectangleBox,
|
||||
} from "./bounds";
|
||||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
|
||||
export const hitTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
@ -72,22 +79,39 @@ export const hitTest = (
|
||||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
return isPointHittingElementBoundingBox(
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
||||
const isHittingBoundTextElement = hitTest(
|
||||
boundTextElement,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
if (isHittingBoundTextElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||
return isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
point,
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
@ -96,19 +120,33 @@ 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
|
||||
// eg for linear elements text can be outside the element bounding box
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
!isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
[x, y],
|
||||
) &&
|
||||
isPointHittingElementBoundingBox(
|
||||
element,
|
||||
[x, y],
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementNotConsideringBoundingBox = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
|
||||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
};
|
||||
|
||||
const isElementSelected = (
|
||||
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
[x, y]: Point,
|
||||
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 elementCenterX = (x1 + x2) / 2;
|
||||
const elementCenterY = (y1 + y2) / 2;
|
||||
@ -157,7 +216,13 @@ export const bindingBorderTest = (
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const check = isOutsideCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
@ -177,6 +242,7 @@ type HitTestArgs = {
|
||||
point: Point;
|
||||
threshold: number;
|
||||
check: (distance: number, threshold: number) => boolean;
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null;
|
||||
};
|
||||
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
@ -248,7 +336,8 @@ const distanceToRectangle = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@ -258,6 +347,14 @@ 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 = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
@ -457,8 +554,7 @@ const pointRelativeToElement = (
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
@ -466,9 +562,26 @@ const pointRelativeToElement = (
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const [ax, ay, bx, by] = elementCoords;
|
||||
const halfWidth = (bx - ax) / 2;
|
||||
const halfHeight = (by - ay) / 2;
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
|
||||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
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`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const translate = GA.reverse(
|
||||
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
|
||||
return GATransform.compose(rotate, translate);
|
||||
};
|
||||
|
||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||
const coordsCenter = (
|
||||
x1: number,
|
||||
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
|
||||
@ -531,6 +649,7 @@ export const determineFocusDistance = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
@ -548,7 +667,7 @@ export const determineFocusPoint = (
|
||||
): Point => {
|
||||
if (focus === 0) {
|
||||
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);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
@ -563,6 +682,7 @@ export const determineFocusPoint = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
@ -646,7 +767,8 @@ const getCorners = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
@ -655,6 +777,7 @@ const getCorners = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@ -16,10 +18,31 @@ export const dragSelectedElements = (
|
||||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
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(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
@ -38,7 +61,13 @@ export const dragSelectedElements = (
|
||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
if (
|
||||
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(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
@ -50,7 +79,7 @@ export const dragSelectedElements = (
|
||||
}
|
||||
}
|
||||
updateBoundElements(element, {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
@ -49,7 +50,11 @@ export {
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedFrames = (
|
||||
frames: readonly ExcalidrawFrameElement[],
|
||||
) =>
|
||||
frames.filter(
|
||||
(frame) => !frame.isDeleted,
|
||||
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
@ -594,7 +594,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
appState: AppState,
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@ -20,15 +21,13 @@ import {
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
@ -44,7 +43,6 @@ import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
@ -53,6 +51,7 @@ type ElementConstructorOpts = MarkOptional<
|
||||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
@ -85,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
height = 0,
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
@ -109,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
@ -129,6 +130,21 @@ export const newElement = (
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_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 */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
@ -161,6 +177,7 @@ export const newTextElement = (
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
@ -195,6 +212,7 @@ export const newTextElement = (
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@ -211,8 +229,6 @@ const getAdjustedDimensions = (
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
@ -268,27 +284,6 @@ const getAdjustedDimensions = (
|
||||
);
|
||||
}
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
@ -638,6 +633,10 @@ export const duplicateElements = (
|
||||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
@ -160,12 +161,17 @@ const rotateSingleElement = (
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
let angle: number;
|
||||
if (isFrameElement(element)) {
|
||||
angle = 0;
|
||||
} else {
|
||||
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);
|
||||
|
||||
mutateElement(element, { angle });
|
||||
@ -877,39 +883,49 @@ const rotateMultipleElements = (
|
||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
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,
|
||||
);
|
||||
mutateElement(element, {
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
|
||||
boundTextElementId,
|
||||
);
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
|
||||
elements
|
||||
.filter((element) => element.type !== "frame")
|
||||
.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,
|
||||
);
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
},
|
||||
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();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
@ -840,10 +840,12 @@ export const getTextBindableContainerAtPosition = (
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
||||
x,
|
||||
y,
|
||||
])
|
||||
isHittingElementNotConsideringBoundingBox(
|
||||
elements[index],
|
||||
appState,
|
||||
null,
|
||||
[x, y],
|
||||
)
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
|
@ -26,6 +26,17 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getTextEditor = () => {
|
||||
return document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
|
||||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
textarea = getTextEditor();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
textarea = getTextEditor();
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
|
||||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
|
||||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
expect(() => updateTextEditor(editor, "")).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
|
||||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(freedraw.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
|
||||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
|
||||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
|
||||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
|
||||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
|
||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
|
||||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
updateTextEditor(editor, " ");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
height: 75,
|
||||
width: 90,
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
const originalRectHeight = container.height;
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Online whiteboard collaboration made easy",
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||
expect(h.elements[0].boundElements).toEqual([]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
|
||||
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
mouse.select(rectangle);
|
||||
@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
editor.select();
|
||||
});
|
||||
|
||||
@ -1459,17 +1382,12 @@ describe("textWysiwyg", () => {
|
||||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@ -23,12 +23,10 @@ import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
@ -36,6 +34,7 @@ import {
|
||||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@ -162,7 +161,7 @@ export const textWysiwyg = ({
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
@ -179,15 +178,6 @@ export const textWysiwyg = ({
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
@ -232,22 +222,12 @@ export const textWysiwyg = ({
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
@ -388,25 +368,6 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
||||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_FRAME = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
@ -249,6 +257,10 @@ export const getTransformHandles = (
|
||||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameElement(element)) {
|
||||
omitSides = {
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING + 8
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
} from "./types";
|
||||
|
||||
@ -45,6 +46,12 @@ export const isTextElement = (
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
|
@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements:
|
||||
| readonly Readonly<{
|
||||
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
"fileId"
|
||||
>;
|
||||
|
||||
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
type: "frame";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
@ -117,7 +123,8 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
@ -148,7 +155,8 @@ export type ExcalidrawBindableElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
|
@ -157,6 +157,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
@ -168,7 +170,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
@ -380,6 +381,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.onUsernameChange(username);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { LanguageList } from "./LanguageList";
|
||||
export const AppMainMenu: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
@ -13,10 +14,12 @@ export const AppMainMenu: React.FC<{
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
|
@ -6,6 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
let headingContent;
|
||||
@ -46,9 +47,11 @@ export const AppWelcomeScreen: React.FC<{
|
||||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreen.Center.MenuItemLink
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
|
@ -42,6 +42,7 @@ import {
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
@ -98,7 +99,7 @@ languageDetector.init({
|
||||
});
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI;
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
@ -183,7 +184,7 @@ const initializeScene = async (opts: {
|
||||
}
|
||||
}
|
||||
|
||||
if (roomLinkData) {
|
||||
if (roomLinkData && opts.collabAPI) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
@ -237,6 +238,7 @@ export const appLangCodeAtom = atom(
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -272,7 +274,7 @@ const ExcalidrawWrapper = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collabAPI || !excalidrawAPI) {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -283,7 +285,7 @@ const ExcalidrawWrapper = () => {
|
||||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
if (collabAPI.isCollaborating()) {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
.fetchImageFilesFromFirebase({
|
||||
@ -353,7 +355,7 @@ const ExcalidrawWrapper = () => {
|
||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI.isCollaborating() &&
|
||||
collabAPI?.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
@ -382,7 +384,10 @@ const ExcalidrawWrapper = () => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
if (!document.hidden && !collabAPI.isCollaborating()) {
|
||||
if (
|
||||
!document.hidden &&
|
||||
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
@ -398,7 +403,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI.setUsername(username || "");
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
||||
@ -466,7 +471,7 @@ const ExcalidrawWrapper = () => {
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@ -649,7 +654,7 @@ const ExcalidrawWrapper = () => {
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile) {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -663,15 +668,21 @@ const ExcalidrawWrapper = () => {
|
||||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
|
||||
<AppFooter />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
</Excalidraw>
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
|
705
src/frame.ts
Normal file
705
src/frame.ts
Normal file
@ -0,0 +1,705 @@
|
||||
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;
|
||||
};
|
@ -94,6 +94,31 @@ export const selectGroupsForSelectedElements = (
|
||||
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 = (
|
||||
appState: AppState,
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
@ -186,3 +211,18 @@ export const getMaximumGroups = (
|
||||
|
||||
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;
|
||||
};
|
||||
|
@ -42,6 +42,7 @@ export const KEYS = {
|
||||
CHEVRON_RIGHT: ">",
|
||||
PERIOD: ".",
|
||||
COMMA: ",",
|
||||
SUBTRACT: "-",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
|
449
src/locales/az-AZ.json
Normal file
449
src/locales/az-AZ.json
Normal file
@ -0,0 +1,449 @@
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "Vložit",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Vložit jako prostý text",
|
||||
"pasteCharts": "Vložit grafy",
|
||||
"selectAll": "Vybrat vše",
|
||||
"multiSelect": "Přidat prvek do výběru",
|
||||
@ -49,9 +49,9 @@
|
||||
"large": "Velké",
|
||||
"veryLarge": "Velmi velké",
|
||||
"solid": "Plný",
|
||||
"hachure": "",
|
||||
"zigzag": "",
|
||||
"crossHatch": "",
|
||||
"hachure": "Hachure",
|
||||
"zigzag": "Klikatě",
|
||||
"crossHatch": "Křížový šrafování",
|
||||
"thin": "Tenký",
|
||||
"bold": "Tlustý",
|
||||
"left": "Vlevo",
|
||||
@ -60,7 +60,7 @@
|
||||
"extraBold": "Extra tlustý",
|
||||
"architect": "Architekt",
|
||||
"artist": "Umělec",
|
||||
"cartoonist": "",
|
||||
"cartoonist": "Kartoonista",
|
||||
"fileTitle": "Název souboru",
|
||||
"colorPicker": "Výběr barvy",
|
||||
"canvasColors": "Použito na plátně",
|
||||
@ -106,7 +106,7 @@
|
||||
"increaseFontSize": "Zvětšit písmo",
|
||||
"unbindText": "Zrušit vazbu textu",
|
||||
"bindText": "Vázat text s kontejnerem",
|
||||
"createContainerFromText": "",
|
||||
"createContainerFromText": "Zabalit text do kontejneru",
|
||||
"link": {
|
||||
"edit": "Upravit odkaz",
|
||||
"create": "Vytvořit odkaz",
|
||||
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Zveřejněno",
|
||||
"sidebarLock": "Ponechat postranní panel otevřený",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Vyberte barvu z plátna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Dosud neexistují žádné položky...",
|
||||
@ -177,38 +177,38 @@
|
||||
"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.",
|
||||
"loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?",
|
||||
"collabStopOverridePrompt": "",
|
||||
"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)",
|
||||
"errorAddingToLibrary": "Položku nelze přidat do knihovny",
|
||||
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
|
||||
"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?",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
"cannotRestoreFromImage": "Scénu nelze obnovit z tohoto souboru obrázku",
|
||||
"invalidSceneUrl": "Nelze importovat scénu z zadané URL. Je buď poškozená, nebo neobsahuje platná JSON data Excalidraw.",
|
||||
"resetLibrary": "Tímto vymažete vaši knihovnu. Jste si jisti?",
|
||||
"removeItemsFromsLibrary": "Smazat {{count}} položek z knihovny?",
|
||||
"invalidEncryptionKey": "Šifrovací klíč musí mít 22 znaků. Live spolupráce je zakázána.",
|
||||
"collabOfflineWarning": "Není k dispozici žádné internetové připojení.\nVaše změny nebudou uloženy!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"unsupportedFileType": "Nepodporovaný typ souboru.",
|
||||
"imageInsertError": "Nelze vložit obrázek. Zkuste to později...",
|
||||
"fileTooBig": "Soubor je příliš velký. Maximální povolená velikost je {{maxSize}}.",
|
||||
"svgImageInsertError": "Nelze vložit SVG obrázek. Značení SVG je neplatné.",
|
||||
"invalidSVGString": "Neplatný SVG.",
|
||||
"cannotResolveCollabServer": "Nelze se připojit ke sdílenému serveru. Prosím obnovte stránku a zkuste to znovu.",
|
||||
"importLibraryError": "Nelze načíst knihovnu",
|
||||
"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_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.",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
"line1": "Vypadá to, že používáte Brave prohlížeč s povoleným nastavením <bold>Aggressively Block Fingerprinting</bold>.",
|
||||
"line2": "To by mohlo vést k narušení <bold>Textových elementů</bold> ve vašich výkresech.",
|
||||
"line3": "Důrazně doporučujeme zakázat toto nastavení. Můžete sledovat <link>tyto kroky</link> jak to udělat.",
|
||||
"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>"
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Výběr",
|
||||
"image": "",
|
||||
"image": "Vložit obrázek",
|
||||
"rectangle": "Obdélník",
|
||||
"diamond": "Diamant",
|
||||
"ellipse": "Elipsa",
|
||||
@ -216,69 +216,69 @@
|
||||
"line": "Čára",
|
||||
"freedraw": "Kreslení",
|
||||
"text": "Text",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"library": "Knihovna",
|
||||
"lock": "Po kreslení ponechat vybraný nástroj aktivní",
|
||||
"penMode": "Režim Pera - zabránit dotyku",
|
||||
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
|
||||
"eraser": "Guma",
|
||||
"hand": ""
|
||||
"hand": "Ruka (nástroj pro posouvání)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"canvasActions": "Akce plátna",
|
||||
"selectedShapeActions": "Akce vybraného tvaru",
|
||||
"shapes": "Tvary"
|
||||
},
|
||||
"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": ""
|
||||
"canvasPanning": "Chcete-li přesunout plátno, podržte kolečko nebo mezerník při tažení nebo použijte nástroj Ruka",
|
||||
"linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru",
|
||||
"freeDraw": "Klikněte a táhněte, pro ukončení pusťte",
|
||||
"text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr",
|
||||
"text_selected": "Dvojklikem nebo stisknutím klávesy ENTER upravíte text",
|
||||
"text_editing": "Stiskněte Escape nebo Ctrl/Cmd+ENTER pro dokončení úprav",
|
||||
"linearElementMulti": "Klikněte na poslední bod nebo stiskněte Escape anebo Enter pro dokončení",
|
||||
"lockAngle": "Úhel můžete omezit podržením SHIFT",
|
||||
"resize": "Můžete omezit proporce podržením SHIFT při změně velikosti,\npodržte ALT pro změnu velikosti od středu",
|
||||
"resizeImage": "Můžete volně změnit velikost podržením SHIFT,\npodržením klávesy ALT změníte velikosti od středu",
|
||||
"rotate": "Úhly můžete omezit podržením SHIFT při otáčení",
|
||||
"lineEditor_info": "Podržte Ctrl/Cmd a dvakrát klikněte nebo stiskněte Ctrl/Cmd + Enter pro úpravu bodů",
|
||||
"lineEditor_pointSelected": "Stisknutím tlačítka Delete odstraňte bod(y),\nCtrl/Cmd+D pro duplicitu nebo táhnutím pro přesun",
|
||||
"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",
|
||||
"placeImage": "Kliknutím umístěte obrázek, nebo klepnutím a přetažením ručně nastavíte jeho velikost",
|
||||
"publishLibrary": "Publikovat vlastní knihovnu",
|
||||
"bindTextToElement": "Stiskněte Enter pro přidání textu",
|
||||
"deepBoxSelect": "Podržte Ctrl/Cmd pro hluboký výběr a pro zabránění táhnutí",
|
||||
"eraserRevert": "Podržením klávesy Alt vrátíte prvky označené pro smazání",
|
||||
"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\"."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
"cannotShowPreview": "Náhled nelze zobrazit",
|
||||
"canvasTooBig": "Plátno je možná příliš velké.",
|
||||
"canvasTooBigTip": "Tip: zkus posunout nejvzdálenější prvky trochu blíže k sobě."
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"headingMain": "Chyba. Zkuste <button>znovu načíst stránku</button>.",
|
||||
"clearCanvasMessage": "Pokud opětovné načtení nefunguje, zkuste <button>vymazat plátno</button>.",
|
||||
"clearCanvasCaveat": " To povede ke ztrátě dat ",
|
||||
"trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
|
||||
"openIssueMessage": "",
|
||||
"sceneContent": ""
|
||||
"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.",
|
||||
"sceneContent": "Obsah scény:"
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
"desc_intro": "Můžete pozvat lidi na vaši aktuální scénu ke spolupráci s vámi.",
|
||||
"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.",
|
||||
"button_startSession": "Zahájit relaci",
|
||||
"button_stopSession": "Ukončit relaci",
|
||||
"desc_inProgressIntro": "Živá spolupráce právě probíhá.",
|
||||
"desc_shareLink": "Sdílejte tento odkaz s každým, s kým chcete spolupracovat:",
|
||||
"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.",
|
||||
"shareTitle": "Připojte se k aktivní spolupráci na Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
"title": "Chyba"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_title": "Uložit na disk",
|
||||
"disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.",
|
||||
"disk_button": "Uložit do souboru",
|
||||
"link_title": "Odkaz pro sdílení",
|
||||
"link_details": "Exportovat jako odkaz pouze pro čtení.",
|
||||
@ -290,18 +290,18 @@
|
||||
"helpDialog": {
|
||||
"blog": "Přečtěte si náš blog",
|
||||
"click": "kliknutí",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"deepSelect": "Hluboký výběr",
|
||||
"deepBoxSelect": "Hluboký výběr uvnitř boxu a zabránění táhnnutí",
|
||||
"curvedArrow": "Zakřivená šipka",
|
||||
"curvedLine": "Zakřivená čára",
|
||||
"documentation": "Dokumentace",
|
||||
"doubleClick": "dvojklik",
|
||||
"drag": "tažení",
|
||||
"editor": "Editor",
|
||||
"editLineArrowPoints": "",
|
||||
"editText": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"editLineArrowPoints": "Upravit body linií/šipek",
|
||||
"editText": "Upravit text / přidat popis",
|
||||
"github": "Našel jsi problém? Nahlaš ho",
|
||||
"howto": "Sledujte naše návody",
|
||||
"or": "nebo",
|
||||
"preventBinding": "Zabránit vázání šipky",
|
||||
"tools": "Nástroje",
|
||||
@ -313,8 +313,8 @@
|
||||
"zoomToFit": "Přiblížit na zobrazení všech prvků",
|
||||
"zoomToSelection": "Přiblížit na výběr",
|
||||
"toggleElementLock": "Zamknout/odemknout výběr",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Posunout stránku nahoru/dolů",
|
||||
"movePageLeftRight": "Přesunout stránku doleva/doprava"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"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é.",
|
||||
"noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
|
||||
"noteLicense": "",
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"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í.",
|
||||
"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:",
|
||||
"atleastOneLibItem": "Vyberte alespoň jednu položku knihovny, kterou chcete začít",
|
||||
"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í."
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Knihovna byla odeslána",
|
||||
"content": ""
|
||||
"content": "Děkujeme vám {{authorName}}. Vaše knihovna byla odeslána k posouzení. Stav můžete sledovat <link>zde</link>"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromLib": ""
|
||||
"resetLibrary": "Resetovat knihovnu",
|
||||
"removeItemsFromLib": "Odstranit vybrané položky z knihovny"
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"header": "Exportovat obrázek",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"withBackground": "Pozadí",
|
||||
"onlySelected": "Pouze vybrané",
|
||||
"darkMode": "Tmavý režim",
|
||||
"embedScene": "Vložit scénu",
|
||||
"scale": "Měřítko",
|
||||
"padding": "Odsazení"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
"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."
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "Exportovat do PNG",
|
||||
"exportToSvg": "Exportovat do SVG",
|
||||
"copyPngToClipboard": "Kopírovat PNG do schránky"
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "PNG",
|
||||
"exportToSvg": "SVG",
|
||||
"copyPngToClipboard": "Kopírovat do schránky"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "",
|
||||
"link": ""
|
||||
"tooltip": "Vaše kresby jsou end-to-end šifrované, takže servery Excalidraw je nikdy neuvidí.",
|
||||
"link": "Blog příspěvek na end-to-end šifrování v Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Úhel",
|
||||
@ -402,48 +402,48 @@
|
||||
"addedToLibrary": "Přidáno do knihovny",
|
||||
"copyStyles": "Styly byly zkopírovány.",
|
||||
"copyToClipboard": "Zkopírováno do schránky.",
|
||||
"copyToClipboardAsPng": "",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} zkopírován do schránky jako PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "Soubor byl uložen.",
|
||||
"fileSavedToFilename": "Uloženo do {filename}",
|
||||
"canvas": "plátno",
|
||||
"selection": "výběr",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Pomocí {{shortcut}} vložte jako jeden prvek,\nnebo vložte do existujícího textového editoru"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "Průhledná",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
"black": "Černá",
|
||||
"white": "Bílá",
|
||||
"red": "Červená",
|
||||
"pink": "Růžová",
|
||||
"grape": "Vínová",
|
||||
"violet": "Fialová",
|
||||
"gray": "Šedá",
|
||||
"blue": "Modrá",
|
||||
"cyan": "Azurová",
|
||||
"teal": "Modrozelená",
|
||||
"green": "Zelená",
|
||||
"yellow": "Žlutá",
|
||||
"orange": "Oranžová",
|
||||
"bronze": "Bronzová"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
"center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.",
|
||||
"center_heading_plus": "Chcete místo toho přejít na Excalidraw+?",
|
||||
"menuHint": "Export, nastavení, jazyky, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
"menuHint": "Export, nastavení a další...",
|
||||
"center_heading": "Diagramy. Vytvořeny. Jednoduše.",
|
||||
"toolbarHint": "Vyberte nástroj a začněte kreslit!",
|
||||
"helpHint": "Zkratky a pomoc"
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
"mostUsedCustomColors": "Nejpoužívanější vlastní barvy",
|
||||
"colors": "Barvy",
|
||||
"shades": "Stíny",
|
||||
"hexCode": "Hex kód",
|
||||
"noShades": "Pro tuto barvu nejsou k dispozici žádné odstíny"
|
||||
}
|
||||
}
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Veröffentlicht",
|
||||
"sidebarLock": "Seitenleiste offen lassen",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Farbe von der Zeichenfläche auswählen"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Noch keine Elemente hinzugefügt...",
|
||||
|
@ -124,6 +124,8 @@
|
||||
},
|
||||
"statusPublished": "Published",
|
||||
"sidebarLock": "Keep sidebar open",
|
||||
"selectAllElementsInFrame": "Select all elements in frame",
|
||||
"removeAllElementsFromFrame": "Remove all elements from frame",
|
||||
"eyeDropper": "Pick color from canvas"
|
||||
},
|
||||
"library": {
|
||||
@ -221,7 +223,9 @@
|
||||
"penMode": "Pen mode - prevent touch",
|
||||
"link": "Add/ Update link for a selected shape",
|
||||
"eraser": "Eraser",
|
||||
"hand": "Hand (panning tool)"
|
||||
"frame": "Frame tool",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "प्रकाशित",
|
||||
"sidebarLock": "साइडबार खुला रखे.",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "चित्रफलक से रंग चुने"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "अभी तक कोई आइटम जोडा नहीं गया.",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "게시됨",
|
||||
"sidebarLock": "사이드바 유지",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "캔버스에서 색상 고르기"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "추가된 아이템 없음",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "प्रकाशित करा",
|
||||
"sidebarLock": "साइडबार उघडं ठेवा",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "चित्रफलकातून रंग निवडा"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "अजून कोणतेही आइटम जोडलेले नाही...",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Publisert",
|
||||
"sidebarLock": "Holde sidemenyen åpen",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Velg farge fra lerretet"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Ingen elementer lagt til ennå...",
|
||||
|
@ -1,11 +1,12 @@
|
||||
{
|
||||
"ar-SA": 80,
|
||||
"az-AZ": 20,
|
||||
"bg-BG": 54,
|
||||
"bn-BD": 60,
|
||||
"ca-ES": 88,
|
||||
"cs-CZ": 64,
|
||||
"cs-CZ": 100,
|
||||
"da-DK": 33,
|
||||
"de-DE": 99,
|
||||
"de-DE": 100,
|
||||
"el-GR": 93,
|
||||
"en": 100,
|
||||
"es-ES": 89,
|
||||
@ -24,32 +25,32 @@
|
||||
"kab-KAB": 88,
|
||||
"kk-KZ": 21,
|
||||
"km-KH": 95,
|
||||
"ko-KR": 99,
|
||||
"ko-KR": 100,
|
||||
"ku-TR": 90,
|
||||
"lt-LT": 56,
|
||||
"lv-LV": 89,
|
||||
"mr-IN": 95,
|
||||
"mr-IN": 96,
|
||||
"my-MM": 41,
|
||||
"nb-NO": 99,
|
||||
"nb-NO": 100,
|
||||
"nl-NL": 83,
|
||||
"nn-NO": 77,
|
||||
"oc-FR": 87,
|
||||
"pa-IN": 90,
|
||||
"pl-PL": 99,
|
||||
"pl-PL": 100,
|
||||
"pt-BR": 99,
|
||||
"pt-PT": 95,
|
||||
"ro-RO": 99,
|
||||
"ru-RU": 99,
|
||||
"ro-RO": 100,
|
||||
"ru-RU": 100,
|
||||
"si-LK": 9,
|
||||
"sk-SK": 99,
|
||||
"sl-SI": 99,
|
||||
"sv-SE": 99,
|
||||
"ta-IN": 83,
|
||||
"sk-SK": 100,
|
||||
"sl-SI": 100,
|
||||
"sv-SE": 100,
|
||||
"ta-IN": 85,
|
||||
"th-TH": 39,
|
||||
"tr-TR": 87,
|
||||
"uk-UA": 97,
|
||||
"vi-VN": 56,
|
||||
"zh-CN": 95,
|
||||
"zh-CN": 100,
|
||||
"zh-HK": 26,
|
||||
"zh-TW": 99
|
||||
"zh-TW": 100
|
||||
}
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Opublikowano",
|
||||
"sidebarLock": "Panel boczny zawsze otwarty",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Wybierz kolor z płótna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Nie dodano jeszcze żadnych elementów...",
|
||||
|
@ -363,7 +363,7 @@
|
||||
"darkMode": "",
|
||||
"embedScene": "Cena embutida",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"padding": "Espaçamento"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": "Păstrează deschisă bara laterală",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Alegere culoare din pânză"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Niciun element adăugat încă...",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Опубликовано",
|
||||
"sidebarLock": "Держать боковую панель открытой",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Взять образец цвета с холста"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Пока ничего не добавлено...",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Zverejnené",
|
||||
"sidebarLock": "Nechať bočný panel otvorený",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Vybrať farbu z plátna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Zatiaľ neboli pridané žiadne položky...",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Objavljeno",
|
||||
"sidebarLock": "Obdrži stransko vrstico odprto",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Izberi barvo s platna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Dodan še ni noben element...",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "Publicerad",
|
||||
"sidebarLock": "Håll sidofältet öppet",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "Välj färg från canvas"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Inga objekt tillagda ännu...",
|
||||
|
@ -50,7 +50,7 @@
|
||||
"veryLarge": "மிகப் பெரிய",
|
||||
"solid": "திடமான",
|
||||
"hachure": "மலைக்குறிக்கோடு",
|
||||
"zigzag": "",
|
||||
"zigzag": "கோணல்மாணல்",
|
||||
"crossHatch": "குறுக்குகதவு",
|
||||
"thin": "மெல்லிய",
|
||||
"bold": "பட்டை",
|
||||
@ -105,8 +105,8 @@
|
||||
"decreaseFontSize": "எழுத்துரு அளவைக் குறை",
|
||||
"increaseFontSize": "எழுத்துரு அளவை அதிகரி",
|
||||
"unbindText": "உரையைப் பிணைவவிழ்",
|
||||
"bindText": "",
|
||||
"createContainerFromText": "",
|
||||
"bindText": "உரையைக் கொள்கலனுக்குப் பிணை",
|
||||
"createContainerFromText": "உரையைக் கொள்கலனுள் சுருட்டு",
|
||||
"link": {
|
||||
"edit": "தொடுப்பைத் திருத்து",
|
||||
"create": "தொடுப்பைப் படை",
|
||||
@ -114,7 +114,7 @@
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "தொடுப்பைத் திருத்து",
|
||||
"exit": ""
|
||||
"exit": "வரி திருத்தியிலிருந்து வெளியேறு"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "பூட்டு",
|
||||
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "வெளியிடப்பட்டது",
|
||||
"sidebarLock": "பக்கப்பட்டையைத் திறந்தே வை",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "கித்தானிலிருந்து நிறம் தேர்ந்தெடு"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "இதுவரை உருப்படிகள் சேரக்கப்படவில்லை...",
|
||||
@ -134,7 +134,7 @@
|
||||
"buttons": {
|
||||
"clearReset": "கித்தானை அகரமாக்கு",
|
||||
"exportJSON": "கோப்புக்கு ஏற்றுமதிசெய்",
|
||||
"exportImage": "",
|
||||
"exportImage": "படத்தை ஏற்றுமதிசெய்...",
|
||||
"export": "இதில் சேமி...",
|
||||
"copyToClipboard": "நகலகத்திற்கு நகலெடு",
|
||||
"save": "தற்போதைய கோப்புக்குச் சேமி",
|
||||
@ -187,7 +187,7 @@
|
||||
"resetLibrary": "இது உங்கள் நுலகத்தைத் துடைக்கும். நீங்கள் உறுதியா?",
|
||||
"removeItemsFromsLibrary": "{{count}} உருப்படி(கள்)-ஐ உம் நூலகத்திலிருந்து அழிக்கவா?",
|
||||
"invalidEncryptionKey": "மறையாக்க விசை 22 வரியுருக்கள் கொண்டிருக்கவேண்டும். நேரடி கூட்டுப்பணி முடக்கப்பட்டது.",
|
||||
"collabOfflineWarning": ""
|
||||
"collabOfflineWarning": "இணைய இணைப்பு இல்லை.\nஉமது மாற்றங்கள் சேமிக்கப்படா!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "ஆதரிக்கப்படா கோப்பு வகை.",
|
||||
@ -195,10 +195,10 @@
|
||||
"fileTooBig": "கோப்பு மிகப்பெரிது. அனுமதிக்கப்பட்ட அதிகபட்ச அளவு {{maxSize}}.",
|
||||
"svgImageInsertError": "எஸ்விஜி படத்தைப் புகுத்தவியலா. எஸ்விஜியின் மார்க்அப் செல்லாததாக தெரிகிறது.",
|
||||
"invalidSVGString": "செல்லாத SVG.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"cannotResolveCollabServer": "கூட்டுப்பணிச் சேவையகத்துடன் இணைக்க முடியவில்லை. பக்கத்தை மீளேற்றி மீண்டும் முயலவும்.",
|
||||
"importLibraryError": "நூலகத்தை ஏற்ற முடியவில்லை",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"collabSaveFailed": "பின்முனை தரவுத்தளத்தில் சேமிக்க முடியவில்லை. சிக்கல்கள் நீடித்தால், உமது வேலைகளை இழக்காமலிருப்பதை உறுதிசெய்ய உமது கோப்பை உள்ளகத்தில் சேமிக்க வேண்டும்.",
|
||||
"collabSaveFailed_sizeExceeded": "பின்முனை தரவுத்தளத்தில் சேமிக்க முடியவில்லை, கித்தான் மிகப்பெரிதாகத் தெரிகிறது. உமது வேலைகளை இழக்காமலிருப்பதை உறுதிசெய்ய உமது கோப்பை உள்ளகத்தில் சேமிக்க வேண்டும்.",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "已发布",
|
||||
"sidebarLock": "侧边栏常驻",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "从画布上取色"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "尚未添加任何项目……",
|
||||
@ -266,12 +266,12 @@
|
||||
"roomDialog": {
|
||||
"desc_intro": "你可以邀请其他人到目前的画面中与你协作。",
|
||||
"desc_privacy": "别担心,该会话使用端到端加密,无论绘制什么都将保持私密,甚至连我们的服务器也无法查看。",
|
||||
"button_startSession": "启动会议",
|
||||
"button_stopSession": "结束会议",
|
||||
"desc_inProgressIntro": "实时协作会议正在进行。",
|
||||
"button_startSession": "开始会话",
|
||||
"button_stopSession": "结束会话",
|
||||
"desc_inProgressIntro": "实时协作会话进行中。",
|
||||
"desc_shareLink": "分享此链接给你要协作的用户",
|
||||
"desc_exitSession": "停止会话将中断您在与房间的连接,但您依然可以在本地继续使用画布. 请注意,这不会影响到其他用户, 他们仍可以在他们的版本上继续协作。",
|
||||
"shareTitle": "加入 Excalidraw 实时协作会议"
|
||||
"desc_exitSession": "停止会话将中断您与房间的连接,但您依然可以在本地继续使用画布。请注意,这不会影响到其他用户,他们仍可以在他们的版本上继续协作。",
|
||||
"shareTitle": "加入 Excalidraw 实时协作会话"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "错误"
|
||||
@ -356,27 +356,27 @@
|
||||
"removeItemsFromLib": "从素材库中删除选中的项目"
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"header": "导出图片",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"withBackground": "背景",
|
||||
"onlySelected": "仅选中",
|
||||
"darkMode": "深色模式",
|
||||
"embedScene": "包含画布数据",
|
||||
"scale": "缩放比例",
|
||||
"padding": "内边距"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
"embedScene": "画布数据将被保存到导出的 PNG/SVG 文件,以便恢复。\n将会增加导出文件的大小。"
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "导出为 PNG",
|
||||
"exportToSvg": "导出为 SVG",
|
||||
"copyPngToClipboard": "复制 PNG 到剪切板"
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "PNG",
|
||||
"exportToSvg": "SVG",
|
||||
"copyPngToClipboard": "复制到剪贴板"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
|
@ -124,7 +124,7 @@
|
||||
},
|
||||
"statusPublished": "已發布",
|
||||
"sidebarLock": "側欄維持開啟",
|
||||
"eyeDropper": ""
|
||||
"eyeDropper": "從畫布中選取顏色"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "尚未加入任何物件...",
|
||||
|
@ -206,7 +206,7 @@ export const isPointInPolygon = (
|
||||
|
||||
// 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.
|
||||
const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
|
||||
export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
|
||||
return (
|
||||
q[0] <= Math.max(p[0], r[0]) &&
|
||||
q[0] >= Math.min(p[0], r[0]) &&
|
||||
|
@ -205,6 +205,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
height: 141.9765625,
|
||||
seed: 1968410350,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
boundElements: null,
|
||||
locked: false,
|
||||
link: null,
|
||||
|
@ -20,6 +20,7 @@ export default {
|
||||
height: 141.9765625,
|
||||
seed: 1968410350,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
},
|
||||
{
|
||||
id: "-xMIs_0jIFqvpx-R9UnaG",
|
||||
@ -37,6 +38,7 @@ export default {
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
seed: 957947807,
|
||||
version: 47,
|
||||
versionNonce: 1128618623,
|
||||
@ -58,6 +60,7 @@ export default {
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
seed: 707269846,
|
||||
version: 143,
|
||||
@ -94,6 +97,7 @@ export default {
|
||||
height: 103.65107323746608,
|
||||
seed: 1445523839,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -133,6 +137,7 @@ export default {
|
||||
height: 113.8575037534261,
|
||||
seed: 1513238033,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -182,6 +187,7 @@ export default {
|
||||
height: 9.797916664247975,
|
||||
seed: 683951089,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -220,6 +226,7 @@ export default {
|
||||
height: 9.797916664247975,
|
||||
seed: 1817746897,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -258,6 +265,7 @@ export default {
|
||||
height: 17.72670397681366,
|
||||
seed: 1409727409,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
||||
},
|
||||
@ -281,6 +289,7 @@ export default {
|
||||
height: 13.941904362416096,
|
||||
seed: 1073094033,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -304,6 +313,7 @@ export default {
|
||||
height: 13.941904362416096,
|
||||
seed: 526271345,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -327,6 +337,7 @@ export default {
|
||||
height: 13.941904362416096,
|
||||
seed: 243707217,
|
||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -352,6 +363,7 @@ export default {
|
||||
height: 36.77344700318558,
|
||||
seed: 511870335,
|
||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -375,6 +387,7 @@ export default {
|
||||
height: 36.77344700318558,
|
||||
seed: 1283079231,
|
||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -398,6 +411,7 @@ export default {
|
||||
height: 36.77344700318558,
|
||||
seed: 996251633,
|
||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -421,6 +435,7 @@ export default {
|
||||
height: 36.77344700318558,
|
||||
seed: 1764842481,
|
||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -446,6 +461,7 @@ export default {
|
||||
height: 154.56722543646003,
|
||||
seed: 1424381745,
|
||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -495,6 +511,7 @@ export default {
|
||||
height: 12.698053371678215,
|
||||
seed: 726657713,
|
||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -533,6 +550,7 @@ export default {
|
||||
height: 10.178760037658167,
|
||||
seed: 1977326481,
|
||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -571,6 +589,7 @@ export default {
|
||||
height: 22.797152568995934,
|
||||
seed: 1774660383,
|
||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
||||
},
|
||||
@ -596,6 +615,7 @@ export default {
|
||||
height: 107.25081879410921,
|
||||
seed: 371096063,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [
|
||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||
@ -623,6 +643,7 @@ export default {
|
||||
height: 107.25081879410921,
|
||||
seed: 685932433,
|
||||
groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [
|
||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||
@ -650,6 +671,7 @@ export default {
|
||||
height: 107.25081879410921,
|
||||
seed: 58634943,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [
|
||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||
@ -677,6 +699,7 @@ export default {
|
||||
height: 3.249953844290203,
|
||||
seed: 1673003743,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -708,6 +731,7 @@ export default {
|
||||
height: 2.8032978840147194,
|
||||
seed: 1821527807,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -739,6 +763,7 @@ export default {
|
||||
height: 4.280657518731036,
|
||||
seed: 1485707039,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -771,6 +796,7 @@ export default {
|
||||
height: 2.9096445412231735,
|
||||
seed: 1042012991,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -804,6 +830,7 @@ export default {
|
||||
height: 2.4757501798128,
|
||||
seed: 295443295,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -835,6 +862,7 @@ export default {
|
||||
height: 2.4757501798128,
|
||||
seed: 1734301567,
|
||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
points: [
|
||||
@ -869,6 +897,7 @@ export default {
|
||||
height: 76.53703389977764,
|
||||
seed: 106569279,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -892,6 +921,7 @@ export default {
|
||||
height: 0,
|
||||
seed: 73916127,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -924,6 +954,7 @@ export default {
|
||||
height: 5.001953125,
|
||||
seed: 387857791,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -947,6 +978,7 @@ export default {
|
||||
height: 5.001953125,
|
||||
seed: 1486370207,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -970,6 +1002,7 @@ export default {
|
||||
height: 5.001953125,
|
||||
seed: 610150847,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -993,6 +1026,7 @@ export default {
|
||||
height: 42.72020253937572,
|
||||
seed: 144280593,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1016,6 +1050,7 @@ export default {
|
||||
height: 24.44112284281997,
|
||||
seed: 29167967,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -1068,6 +1103,7 @@ export default {
|
||||
height: 0,
|
||||
seed: 1443027377,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -1100,6 +1136,7 @@ export default {
|
||||
height: 5.711199931375845,
|
||||
seed: 244310513,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -1138,6 +1175,7 @@ export default {
|
||||
height: 44.82230388130942,
|
||||
seed: 683572113,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1161,6 +1199,7 @@ export default {
|
||||
height: 5.896061363392446,
|
||||
seed: 318798801,
|
||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||
frameId: null,
|
||||
strokeSharpness: "round",
|
||||
boundElementIds: [],
|
||||
startBinding: null,
|
||||
@ -1200,6 +1239,7 @@ export default {
|
||||
height: 108.30428902193904,
|
||||
seed: 1914896753,
|
||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1223,6 +1263,7 @@ export default {
|
||||
height: 82.83278895375764,
|
||||
seed: 1306468145,
|
||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1246,6 +1287,7 @@ export default {
|
||||
height: 11.427824006438863,
|
||||
seed: 93422161,
|
||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1269,6 +1311,7 @@ export default {
|
||||
height: 19.889460471185775,
|
||||
seed: 11646495,
|
||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
@ -1292,6 +1335,7 @@ export default {
|
||||
height: 19.889460471185775,
|
||||
seed: 291717649,
|
||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||
frameId: null,
|
||||
strokeSharpness: "sharp",
|
||||
boundElementIds: [],
|
||||
},
|
||||
|
@ -44,7 +44,8 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude: /node_modules\/(?!browser-fs-access)/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
use: [
|
||||
{
|
||||
loader: "ts-loader",
|
||||
|
@ -46,7 +46,9 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude: /node_modules\/(?!browser-fs-access)/,
|
||||
exclude:
|
||||
/node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/,
|
||||
|
||||
use: [
|
||||
{
|
||||
loader: "ts-loader",
|
||||
|
@ -34,6 +34,7 @@ import { AppState, BinaryFiles, Zoom } from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
FRAME_STYLE,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
MIME_TYPES,
|
||||
SVG_NS,
|
||||
@ -48,6 +49,7 @@ import {
|
||||
getBoundTextMaxWidth,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { getContainingFrame } from "../frame";
|
||||
|
||||
// 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
|
||||
@ -92,6 +94,7 @@ export interface ExcalidrawElementWithCanvas {
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
boundTextElementVersion: number | null;
|
||||
containingFrameOpacity: number;
|
||||
}
|
||||
|
||||
const cappedElementCanvasSize = (
|
||||
@ -207,6 +210,7 @@ const generateElementCanvas = (
|
||||
canvasOffsetX,
|
||||
canvasOffsetY,
|
||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||
containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
|
||||
};
|
||||
};
|
||||
|
||||
@ -253,7 +257,8 @@ const drawElementOnCanvas = (
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: RenderConfig,
|
||||
) => {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
context.globalAlpha =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
@ -469,7 +474,7 @@ const generateElementShape = (
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "rectangle": {
|
||||
if (element.roundness) {
|
||||
const w = element.width;
|
||||
const h = element.height;
|
||||
@ -494,6 +499,7 @@ const generateElementShape = (
|
||||
setShapeForElement(element, shape);
|
||||
|
||||
break;
|
||||
}
|
||||
case "diamond": {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
@ -717,12 +723,14 @@ const generateElementWithCanvas = (
|
||||
prevElementWithCanvas.zoomValue !== zoom.value &&
|
||||
!renderConfig?.shouldCacheIgnoreZoom;
|
||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
|
||||
|
||||
if (
|
||||
!prevElementWithCanvas ||
|
||||
shouldRegenerateBecauseZoom ||
|
||||
prevElementWithCanvas.theme !== renderConfig.theme ||
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
|
||||
) {
|
||||
const elementWithCanvas = generateElementCanvas(
|
||||
element,
|
||||
@ -897,25 +905,59 @@ export const renderElement = (
|
||||
const generator = rc.generator;
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + renderConfig.scrollX,
|
||||
element.y + renderConfig.scrollY,
|
||||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
// do not render selection when exporting
|
||||
if (!renderConfig.isExporting) {
|
||||
context.save();
|
||||
context.translate(
|
||||
element.x + renderConfig.scrollX,
|
||||
element.y + renderConfig.scrollY,
|
||||
);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// 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
|
||||
// from right to left
|
||||
const offset = 0.5 / renderConfig.zoom.value;
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// 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
|
||||
// from right to left
|
||||
const offset = 0.5 / renderConfig.zoom.value;
|
||||
|
||||
context.fillRect(offset, offset, element.width, element.height);
|
||||
context.lineWidth = 1 / renderConfig.zoom.value;
|
||||
context.strokeStyle = "rgb(105, 101, 219)";
|
||||
context.strokeRect(offset, offset, element.width, element.height);
|
||||
context.fillRect(offset, offset, element.width, element.height);
|
||||
context.lineWidth = 1 / renderConfig.zoom.value;
|
||||
context.strokeStyle = " rgb(105, 101, 219)";
|
||||
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;
|
||||
}
|
||||
case "freedraw": {
|
||||
@ -1107,6 +1149,23 @@ const roughSVGDrawWithPrecision = (
|
||||
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 = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rsvg: RoughSVG,
|
||||
@ -1115,6 +1174,7 @@ export const renderElementToSvg = (
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
exportWithDarkMode?: boolean,
|
||||
exportingFrameId?: string | null,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
let cx = (x2 - x1) / 2 - (element.x - x1);
|
||||
@ -1148,6 +1208,9 @@ export const renderElementToSvg = (
|
||||
root = anchorTag;
|
||||
}
|
||||
|
||||
const opacity =
|
||||
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
// Since this is used only during editing experience, which is canvas based,
|
||||
@ -1163,7 +1226,6 @@ export const renderElementToSvg = (
|
||||
getShapeForElement(element)!,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
);
|
||||
const opacity = element.opacity / 100;
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
@ -1175,7 +1237,15 @@ export const renderElementToSvg = (
|
||||
offsetY || 0
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
root.appendChild(node);
|
||||
|
||||
const g = maybeWrapNodesInFrameClipPath(
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "line":
|
||||
@ -1228,7 +1298,6 @@ export const renderElementToSvg = (
|
||||
if (boundText) {
|
||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||
}
|
||||
const opacity = element.opacity / 100;
|
||||
group.setAttribute("stroke-linecap", "round");
|
||||
|
||||
getShapeForElement(element)!.forEach((shape) => {
|
||||
@ -1256,14 +1325,24 @@ export const renderElementToSvg = (
|
||||
}
|
||||
group.appendChild(node);
|
||||
});
|
||||
root.appendChild(group);
|
||||
root.append(maskPath);
|
||||
|
||||
const g = maybeWrapNodesInFrameClipPath(
|
||||
element,
|
||||
root,
|
||||
[group, maskPath],
|
||||
exportingFrameId,
|
||||
);
|
||||
if (g) {
|
||||
root.appendChild(g);
|
||||
} else {
|
||||
root.appendChild(group);
|
||||
root.append(maskPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
generateElementShape(element, generator);
|
||||
generateFreeDrawShape(element);
|
||||
const opacity = element.opacity / 100;
|
||||
const shape = getShapeForElement(element);
|
||||
const node = shape
|
||||
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
|
||||
@ -1283,7 +1362,15 @@ export const renderElementToSvg = (
|
||||
path.setAttribute("fill", element.strokeColor);
|
||||
path.setAttribute("d", getFreeDrawSvgPath(element));
|
||||
node.appendChild(path);
|
||||
root.appendChild(node);
|
||||
|
||||
const g = maybeWrapNodesInFrameClipPath(
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
@ -1319,6 +1406,7 @@ export const renderElementToSvg = (
|
||||
|
||||
use.setAttribute("width", `${width}`);
|
||||
use.setAttribute("height", `${height}`);
|
||||
use.setAttribute("opacity", `${opacity}`);
|
||||
|
||||
// We first apply `scale` transforms (horizontal/vertical mirroring)
|
||||
// on the <use> element, then apply translation and rotation
|
||||
@ -1344,13 +1432,22 @@ export const renderElementToSvg = (
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
|
||||
root.appendChild(g);
|
||||
const clipG = maybeWrapNodesInFrameClipPath(
|
||||
element,
|
||||
root,
|
||||
[g],
|
||||
exportingFrameId,
|
||||
);
|
||||
clipG ? root.appendChild(clipG) : root.appendChild(g);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// frames are not rendered and only acts as a container
|
||||
case "frame": {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (isTextElement(element)) {
|
||||
const opacity = element.opacity / 100;
|
||||
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
@ -1395,7 +1492,15 @@ export const renderElementToSvg = (
|
||||
text.setAttribute("dominant-baseline", "text-before-edge");
|
||||
node.appendChild(text);
|
||||
}
|
||||
root.appendChild(node);
|
||||
|
||||
const g = maybeWrapNodesInFrameClipPath(
|
||||
element,
|
||||
root,
|
||||
[node],
|
||||
exportingFrameId,
|
||||
);
|
||||
|
||||
g ? root.appendChild(g) : root.appendChild(node);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
NonDeleted,
|
||||
GroupId,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@ -30,12 +31,13 @@ import {
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
import { getClientColors } from "../clients";
|
||||
import { getClientColor } from "../clients";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
isSelectedViaGroup,
|
||||
getSelectedGroupIds,
|
||||
getElementsInGroup,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "../groups";
|
||||
import { maxBindingGap } from "../element/collision";
|
||||
import {
|
||||
@ -44,24 +46,30 @@ import {
|
||||
isBindingEnabled,
|
||||
} from "../element/binding";
|
||||
import {
|
||||
OMIT_SIDES_FOR_FRAME,
|
||||
shouldShowBoundingBox,
|
||||
TransformHandles,
|
||||
TransformHandleType,
|
||||
} from "../element/transformHandles";
|
||||
import {
|
||||
viewportCoordsToSceneCoords,
|
||||
supportsEmoji,
|
||||
throttleRAF,
|
||||
isOnlyExportingSingleFrame,
|
||||
} from "../utils";
|
||||
import { UserIdleState } from "../types";
|
||||
import { THEME_FILTER } from "../constants";
|
||||
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
||||
import {
|
||||
EXTERNAL_LINK_IMG,
|
||||
getLinkHandleFromCoords,
|
||||
} from "../element/Hyperlink";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { isFrameElement, isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getTargetFrame,
|
||||
isElementInFrame,
|
||||
} from "../frame";
|
||||
import "canvas-roundrect-polyfill";
|
||||
|
||||
const hasEmojiSupport = supportsEmoji();
|
||||
export const DEFAULT_SPACING = 2;
|
||||
|
||||
const strokeRectWithRotation = (
|
||||
@ -74,6 +82,8 @@ const strokeRectWithRotation = (
|
||||
cy: number,
|
||||
angle: number,
|
||||
fill: boolean = false,
|
||||
/** should account for zoom */
|
||||
radius: number = 0,
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
@ -81,7 +91,14 @@ const strokeRectWithRotation = (
|
||||
if (fill) {
|
||||
context.fillRect(x - cx, y - cy, width, height);
|
||||
}
|
||||
context.strokeRect(x - cx, y - cy, width, height);
|
||||
if (radius && context.roundRect) {
|
||||
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();
|
||||
};
|
||||
|
||||
@ -159,7 +176,6 @@ const strokeGrid = (
|
||||
|
||||
const renderSingleLinearPoint = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: AppState,
|
||||
renderConfig: RenderConfig,
|
||||
point: Point,
|
||||
radius: number,
|
||||
@ -206,14 +222,7 @@ const renderLinearPointHandles = (
|
||||
const isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
point,
|
||||
radius,
|
||||
isSelected,
|
||||
);
|
||||
renderSingleLinearPoint(context, renderConfig, point, radius, isSelected);
|
||||
});
|
||||
|
||||
//Rendering segment mid points
|
||||
@ -237,7 +246,6 @@ const renderLinearPointHandles = (
|
||||
if (appState.editingLinearElement) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
@ -248,7 +256,6 @@ const renderLinearPointHandles = (
|
||||
highlightPoint(segmentMidPoint, context, renderConfig);
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
@ -258,7 +265,6 @@ const renderLinearPointHandles = (
|
||||
} else if (appState.editingLinearElement || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
segmentMidPoint,
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
@ -314,6 +320,34 @@ const renderLinearElementPointHighlight = (
|
||||
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 = ({
|
||||
elements,
|
||||
appState,
|
||||
@ -405,11 +439,51 @@ 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 =
|
||||
undefined;
|
||||
|
||||
visibleElements.forEach((element) => {
|
||||
try {
|
||||
renderElement(element, rc, context, renderConfig, appState);
|
||||
// - when exporting the whole canvas, we DO NOT apply clipping
|
||||
// - 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
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
@ -458,7 +532,24 @@ export const _renderScene = ({
|
||||
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 isFrameSelected = locallySelectedElements.some((element) =>
|
||||
isFrameElement(element),
|
||||
);
|
||||
|
||||
// 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
|
||||
@ -527,7 +618,7 @@ export const _renderScene = ({
|
||||
selectionColors.push(
|
||||
...renderConfig.remoteSelectedElementIds[element.id].map(
|
||||
(socketId) => {
|
||||
const { background } = getClientColors(socketId, appState);
|
||||
const background = getClientColor(socketId);
|
||||
return background;
|
||||
},
|
||||
),
|
||||
@ -628,7 +719,9 @@ export const _renderScene = ({
|
||||
0,
|
||||
renderConfig.zoom,
|
||||
"mouse",
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
isFrameSelected
|
||||
? OMIT_SIDES_FOR_FRAME
|
||||
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
);
|
||||
if (locallySelectedElements.some((element) => !element.locked)) {
|
||||
renderTransformHandles(context, renderConfig, transformHandles, 0);
|
||||
@ -647,7 +740,7 @@ export const _renderScene = ({
|
||||
x -= appState.offsetLeft;
|
||||
y -= appState.offsetTop;
|
||||
|
||||
const width = 9;
|
||||
const width = 11;
|
||||
const height = 14;
|
||||
|
||||
const isOutOfBounds =
|
||||
@ -661,15 +754,20 @@ export const _renderScene = ({
|
||||
y = Math.max(y, 0);
|
||||
y = Math.min(y, normalizedCanvasHeight - height);
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const background = getClientColor(clientId);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = stroke;
|
||||
context.strokeStyle = background;
|
||||
context.fillStyle = background;
|
||||
|
||||
const userState = renderConfig.remotePointerUserStates[clientId];
|
||||
if (isOutOfBounds || userState === UserIdleState.AWAY) {
|
||||
context.globalAlpha = 0.48;
|
||||
const isInactive =
|
||||
isOutOfBounds ||
|
||||
userState === UserIdleState.IDLE ||
|
||||
userState === UserIdleState.AWAY;
|
||||
|
||||
if (isInactive) {
|
||||
context.globalAlpha = 0.3;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -686,73 +784,86 @@ export const _renderScene = ({
|
||||
context.beginPath();
|
||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = stroke;
|
||||
context.strokeStyle = background;
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// Background (white outline) for arrow
|
||||
context.fillStyle = oc.white;
|
||||
context.strokeStyle = oc.white;
|
||||
context.lineWidth = 6;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
context.lineTo(x + 1, y + 14);
|
||||
context.lineTo(x + 0, y + 14);
|
||||
context.lineTo(x + 4, y + 9);
|
||||
context.lineTo(x + 9, y + 10);
|
||||
context.lineTo(x, y);
|
||||
context.fill();
|
||||
context.lineTo(x + 11, y + 8);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.fill();
|
||||
|
||||
const username = renderConfig.remotePointerUsernames[clientId];
|
||||
|
||||
let idleState = "";
|
||||
if (userState === UserIdleState.AWAY) {
|
||||
idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
|
||||
} else if (userState === UserIdleState.IDLE) {
|
||||
idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
|
||||
// Arrow
|
||||
context.fillStyle = background;
|
||||
context.strokeStyle = background;
|
||||
context.lineWidth = 2;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
if (isInactive) {
|
||||
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 usernameAndIdleState = `${username || ""}${
|
||||
idleState ? ` ${idleState}` : ""
|
||||
}`;
|
||||
const username = renderConfig.remotePointerUsernames[clientId] || "";
|
||||
|
||||
if (!isOutOfBounds && usernameAndIdleState) {
|
||||
const offsetX = x + width;
|
||||
const offsetY = y + height;
|
||||
const paddingHorizontal = 4;
|
||||
const paddingVertical = 4;
|
||||
const measure = context.measureText(usernameAndIdleState);
|
||||
if (!isOutOfBounds && username) {
|
||||
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||
|
||||
const offsetX = x + width / 2;
|
||||
const offsetY = y + height + 2;
|
||||
const paddingHorizontal = 5;
|
||||
const paddingVertical = 3;
|
||||
const measure = context.measureText(username);
|
||||
const measureHeight =
|
||||
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||
const finalHeight = Math.max(measureHeight, 12);
|
||||
|
||||
const boxX = offsetX - 1;
|
||||
const boxY = offsetY - 1;
|
||||
const boxWidth = measure.width + 2 * paddingHorizontal + 2;
|
||||
const boxHeight = measureHeight + 2 * paddingVertical + 2;
|
||||
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||
if (context.roundRect) {
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
boxX,
|
||||
boxY,
|
||||
boxWidth,
|
||||
boxHeight,
|
||||
4 / renderConfig.zoom.value,
|
||||
);
|
||||
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||
context.fillStyle = background;
|
||||
context.fill();
|
||||
context.fillStyle = stroke;
|
||||
context.strokeStyle = oc.white;
|
||||
context.stroke();
|
||||
} else {
|
||||
// Border
|
||||
context.fillStyle = stroke;
|
||||
context.fillRect(boxX, boxY, boxWidth, boxHeight);
|
||||
// Background
|
||||
context.fillStyle = background;
|
||||
context.fillRect(offsetX, offsetY, boxWidth - 2, boxHeight - 2);
|
||||
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white);
|
||||
}
|
||||
context.fillStyle = oc.white;
|
||||
context.fillStyle = oc.black;
|
||||
|
||||
context.fillText(
|
||||
usernameAndIdleState,
|
||||
offsetX + paddingHorizontal,
|
||||
offsetY + paddingVertical + measure.actualBoundingBoxAscent,
|
||||
username,
|
||||
offsetX + paddingHorizontal + 1,
|
||||
offsetY +
|
||||
paddingVertical +
|
||||
measure.actualBoundingBoxAscent +
|
||||
Math.floor((finalHeight - measureHeight) / 2) +
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
@ -971,6 +1082,7 @@ const renderBindingHighlightForBindableElement = (
|
||||
case "rectangle":
|
||||
case "text":
|
||||
case "image":
|
||||
case "frame":
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
x1 - padding,
|
||||
@ -1008,6 +1120,82 @@ 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 = (
|
||||
context: CanvasRenderingContext2D,
|
||||
suggestedBinding: SuggestedPointBinding,
|
||||
@ -1089,7 +1277,7 @@ const renderLinkIcon = (
|
||||
}
|
||||
};
|
||||
|
||||
const isVisibleElement = (
|
||||
export const isVisibleElement = (
|
||||
element: ExcalidrawElement,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
@ -1135,17 +1323,20 @@ export const renderSceneToSvg = (
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
exportWithDarkMode = false,
|
||||
exportingFrameId = null,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
exportWithDarkMode?: boolean;
|
||||
exportingFrameId?: string | null;
|
||||
} = {},
|
||||
) => {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
// render elements
|
||||
elements.forEach((element, index) => {
|
||||
elements.forEach((element) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
renderElementToSvg(
|
||||
@ -1156,6 +1347,7 @@ export const renderSceneToSvg = (
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
exportWithDarkMode,
|
||||
exportingFrameId,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -15,6 +15,7 @@ export const roundRect = (
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
strokeColor?: string,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.moveTo(x + radius, y);
|
||||
@ -33,5 +34,8 @@ export const roundRect = (
|
||||
context.quadraticCurveTo(x, y, x + radius, y);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
if (strokeColor) {
|
||||
context.strokeStyle = strokeColor;
|
||||
}
|
||||
context.stroke();
|
||||
};
|
||||
|
@ -2,9 +2,15 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import { getNonDeletedElements, isNonDeletedElement } from "../element";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNonDeletedFrames,
|
||||
isNonDeletedElement,
|
||||
} from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isFrameElement } from "../element/typeChecks";
|
||||
|
||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
@ -12,6 +18,10 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
type SceneStateCallback = () => 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 => {
|
||||
if (typeof elementKey === "string") {
|
||||
return true;
|
||||
@ -55,6 +65,8 @@ class Scene {
|
||||
|
||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
||||
private elements: readonly ExcalidrawElement[] = [];
|
||||
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
|
||||
private frames: readonly ExcalidrawFrameElement[] = [];
|
||||
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
|
||||
getElementsIncludingDeleted() {
|
||||
@ -65,6 +77,14 @@ class Scene {
|
||||
return this.nonDeletedElements;
|
||||
}
|
||||
|
||||
getFramesIncludingDeleted() {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
|
||||
return this.nonDeletedFrames;
|
||||
}
|
||||
|
||||
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
|
||||
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||
}
|
||||
@ -110,12 +130,19 @@ class Scene {
|
||||
|
||||
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
||||
this.elements = nextElements;
|
||||
const nextFrames: ExcalidrawFrameElement[] = [];
|
||||
this.elementsMap.clear();
|
||||
nextElements.forEach((element) => {
|
||||
if (isFrameElement(element)) {
|
||||
nextFrames.push(element);
|
||||
}
|
||||
this.elementsMap.set(element.id, element);
|
||||
Scene.mapElementToScene(element, this);
|
||||
});
|
||||
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
||||
this.frames = nextFrames;
|
||||
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
|
||||
|
||||
this.informMutation();
|
||||
}
|
||||
|
||||
@ -165,6 +192,29 @@ class Scene {
|
||||
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) {
|
||||
return this.elements.findIndex((element) => element.id === elementId);
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ export const hasBackground = (type: string) =>
|
||||
type === "line" ||
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: string) => type !== "image";
|
||||
export const hasStrokeColor = (type: string) =>
|
||||
type !== "image" && type !== "frame";
|
||||
|
||||
export const hasStrokeWidth = (type: string) =>
|
||||
type === "rectangle" ||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
|
||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||
import { distance } from "../utils";
|
||||
import { distance, isOnlyExportingSingleFrame } from "../utils";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
@ -11,6 +11,7 @@ import {
|
||||
getInitializedImageElements,
|
||||
updateImageCache,
|
||||
} from "../element/image";
|
||||
import Scene from "./Scene";
|
||||
|
||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@ -51,6 +52,8 @@ export const exportToCanvas = async (
|
||||
files,
|
||||
});
|
||||
|
||||
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||
|
||||
renderScene({
|
||||
elements,
|
||||
appState,
|
||||
@ -59,8 +62,8 @@ export const exportToCanvas = async (
|
||||
canvas,
|
||||
renderConfig: {
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: -minX + exportPadding,
|
||||
scrollY: -minY + exportPadding,
|
||||
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||
zoom: defaultAppState.zoom,
|
||||
remotePointerViewportCoords: {},
|
||||
remoteSelectedElementIds: {},
|
||||
@ -88,6 +91,7 @@ export const exportToSvg = async (
|
||||
viewBackgroundColor: string;
|
||||
exportWithDarkMode?: boolean;
|
||||
exportEmbedScene?: boolean;
|
||||
renderFrame?: boolean;
|
||||
},
|
||||
files: BinaryFiles | null,
|
||||
opts?: {
|
||||
@ -140,6 +144,39 @@ export const exportToSvg = async (
|
||||
}
|
||||
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 = `
|
||||
${SVG_EXPORT_TAG}
|
||||
${metadata}
|
||||
@ -154,8 +191,10 @@ export const exportToSvg = async (
|
||||
src: url("${assetPath}Cascadia.woff2");
|
||||
}
|
||||
</style>
|
||||
${exportingFrameClipPath}
|
||||
</defs>
|
||||
`;
|
||||
|
||||
// render background rect
|
||||
if (appState.exportBackground && viewBackgroundColor) {
|
||||
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
||||
@ -169,9 +208,10 @@ export const exportToSvg = async (
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
offsetX,
|
||||
offsetY,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
exportingFrameId: exportingFrame?.id || null,
|
||||
});
|
||||
|
||||
return svgRoot;
|
||||
@ -182,9 +222,36 @@ const getCanvasSize = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
exportPadding: 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 width = distance(minX, maxX) + exportPadding * 2;
|
||||
const height = distance(minY, maxY) + exportPadding + exportPadding;
|
||||
const width =
|
||||
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||
const height =
|
||||
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||
|
||||
return [minX, minY, width, height];
|
||||
};
|
||||
|
@ -5,17 +5,61 @@ import {
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import { AppState } from "../types";
|
||||
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 = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selection: NonDeletedExcalidrawElement,
|
||||
excludeElementsInFrames: boolean = true,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
getElementAbsoluteCoords(selection);
|
||||
return elements.filter((element) => {
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
|
||||
let elementsInSelection = elements.filter((element) => {
|
||||
let [elementX1, elementY1, elementX2, elementY2] =
|
||||
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 (
|
||||
element.locked === false &&
|
||||
element.type !== "selection" &&
|
||||
@ -26,6 +70,22 @@ export const getElementsWithinSelection = (
|
||||
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 = (
|
||||
@ -56,14 +116,17 @@ export const getCommonAttributeOfSelectedElements = <T>(
|
||||
export const getSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
includeBoundTextElement: boolean = false,
|
||||
) =>
|
||||
elements.filter((element) => {
|
||||
opts?: {
|
||||
includeBoundTextElement?: boolean;
|
||||
includeElementsInFrames?: boolean;
|
||||
},
|
||||
) => {
|
||||
const selectedElements = elements.filter((element) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
return element;
|
||||
}
|
||||
if (
|
||||
includeBoundTextElement &&
|
||||
opts?.includeBoundTextElement &&
|
||||
isBoundToContainer(element) &&
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
@ -72,10 +135,29 @@ export const getSelectedElements = (
|
||||
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 = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
|
||||
) =>
|
||||
appState.editingElement
|
||||
? [appState.editingElement]
|
||||
: getSelectedElements(elements, appState, true);
|
||||
: getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
@ -83,6 +83,14 @@ export const SHAPES = [
|
||||
numericKey: KEYS["0"],
|
||||
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;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ Object {
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -56,6 +57,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -89,6 +91,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -122,6 +125,7 @@ Object {
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -168,6 +172,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
|
@ -15,6 +15,7 @@ exports[`export exporting svg containing transformed images: svg export output 1
|
||||
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
||||
}
|
||||
</style>
|
||||
|
||||
</defs>
|
||||
<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>"
|
||||
<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>"
|
||||
`;
|
||||
|
@ -6,6 +6,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0_copy",
|
||||
@ -37,6 +38,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -68,6 +70,7 @@ Object {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
@ -104,6 +107,7 @@ Object {
|
||||
},
|
||||
],
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 100,
|
||||
"id": "id0",
|
||||
@ -140,6 +144,7 @@ Object {
|
||||
},
|
||||
],
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 300,
|
||||
"id": "id1",
|
||||
@ -177,6 +182,7 @@ Object {
|
||||
"gap": 10,
|
||||
},
|
||||
"fillStyle": "hachure",
|
||||
"frameId": null,
|
||||
"groupIds": Array [],
|
||||
"height": 81.48231043525051,
|
||||
"id": "id2",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user