Compare commits

..
Author SHA1 Message Date
zsviczian 64e43bf7c7 lint 2025-01-17 22:47:50 +00:00
zsviczian 9d24003517 testing 2025-01-17 22:39:06 +00:00
42 changed files with 582 additions and 1061 deletions
+15 -20
View File
@@ -21,8 +21,10 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
export const alignActionsPredicate = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
_: unknown,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@@ -46,7 +48,6 @@ const alignSelectedElements = (
selectedElements, selectedElements,
elementsMap, elementsMap,
alignment, alignment,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -63,8 +64,7 @@ export const actionAlignTop = register({
label: "labels.alignTop", label: "labels.alignTop",
icon: AlignTopIcon, icon: AlignTopIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -97,8 +97,7 @@ export const actionAlignBottom = register({
label: "labels.alignBottom", label: "labels.alignBottom",
icon: AlignBottomIcon, icon: AlignBottomIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -113,7 +112,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -131,8 +130,7 @@ export const actionAlignLeft = register({
label: "labels.alignLeft", label: "labels.alignLeft",
icon: AlignLeftIcon, icon: AlignLeftIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -147,7 +145,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -165,8 +163,7 @@ export const actionAlignRight = register({
label: "labels.alignRight", label: "labels.alignRight",
icon: AlignRightIcon, icon: AlignRightIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -181,7 +178,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -199,8 +196,7 @@ export const actionAlignVerticallyCentered = register({
label: "labels.centerVertically", label: "labels.centerVertically",
icon: CenterVerticallyIcon, icon: CenterVerticallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -213,7 +209,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -229,8 +225,7 @@ export const actionAlignHorizontallyCentered = register({
label: "labels.centerHorizontally", label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon, icon: CenterHorizontallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, appProps, app) => predicate: alignActionsPredicate,
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -243,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(appState, app)} hidden={!alignActionsPredicate(elements, appState, null, app)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -12,8 +12,6 @@ import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement"; import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement";
const isSingleFrameSelected = ( const isSingleFrameSelected = (
appState: UIAppState, appState: UIAppState,
@@ -176,31 +174,10 @@ export const actionWrapSelectionInFrame = register({
height: y2 - y1 + PADDING * 2, height: y2 - y1 + PADDING * 2,
}); });
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
}
}
const nextElements = addElementsToFrame( const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
+5 -12
View File
@@ -25,10 +25,8 @@ import type {
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameLikeElements, getFrameLikeElements,
getRootElements,
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
@@ -62,11 +60,8 @@ const enableActionGroup = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
return ( return (
selectedElements.length >= 2 && selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
); );
}; };
@@ -76,12 +71,10 @@ export const actionGroup = register({
icon: (appState) => <GroupIcon theme={appState.theme} />, icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getRootElements( const selectedElements = app.scene.getSelectedElements({
app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds,
selectedElementIds: appState.selectedElementIds, includeBoundTextElement: true,
includeBoundTextElement: true, });
}),
);
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: StoreAction.NONE };
@@ -89,7 +89,6 @@ import type {
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -116,12 +115,10 @@ import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding, getHoveredElementForBinding,
updateBoundElements,
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
import { Range } from "../components/Range";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -221,47 +218,33 @@ const changeFontSize = (
) => { ) => {
const newFontSizes = new Set<number>(); const newFontSizes = new Set<number>();
const updatedElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
}
});
return { return {
elements: updatedElements, elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: { appState: {
...appState, ...appState,
// update state only if we've set all select text elements to // update state only if we've set all select text elements to
@@ -631,12 +614,25 @@ export const actionChangeOpacity = register({
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<Range <label className="control-label">
updateData={updateData} {t("labels.opacity")}
elements={elements} <input
appState={appState} type="range"
testId="opacity" min="0"
/> max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
value={
getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
/>
</label>
), ),
}); });
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -19,17 +20,17 @@ export const actionSelectAll = register({
return false; return false;
} }
const selectedElementIds = elements const selectedElementIds = excludeElementsInFramesFromSelection(
.filter( elements.filter(
(element) => (element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked, !element.locked,
) ),
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => { ).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true; map[element.id] = true;
return map; return map;
}, {}); }, {});
return { return {
appState: { appState: {
+5 -14
View File
@@ -1,10 +1,8 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { mutateElement } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds"; import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds"; import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -15,7 +13,6 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
@@ -29,18 +26,12 @@ export const alignElements = (
selectionBoundingBox, selectionBoundingBox,
alignment, alignment,
); );
return group.map((element) => { return group.map((element) =>
// update element newElementWith(element, {
const updatedEle = mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
// update bound elements );
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group,
});
return updatedEle;
});
}); });
}; };
+1 -7
View File
@@ -51,7 +51,6 @@ import {
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { alignActionsPredicate } from "../actions/actionAlign";
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
@@ -91,12 +90,10 @@ export const SelectedShapeActions = ({
appState, appState,
elementsMap, elementsMap,
renderAction, renderAction,
app,
}: { }: {
appState: UIAppState; appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => { }) => {
const targetElements = getTargetElements(elementsMap, appState); const targetElements = getTargetElements(elementsMap, appState);
@@ -136,9 +133,6 @@ export const SelectedShapeActions = ({
targetElements.length === 1 && targetElements.length === 1 &&
isImageElement(targetElements[0]); isImageElement(targetElements[0]);
const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -206,7 +200,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
{showAlignActions && !isSingleElementBoundContainer && ( {targetElements.length > 1 && !isSingleElementBoundContainer && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
<div className="buttonList"> <div className="buttonList">
+8 -20
View File
@@ -3235,12 +3235,7 @@ class App extends React.Component<AppProps, AppState> {
newElements, newElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame( addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
nextElements,
eligibleElements,
topLayerFrame,
this.state,
);
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -4331,14 +4326,10 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement( mutateElement(element, {
element, x: element.x + offsetX,
{ y: element.y + offsetY,
x: element.x + offsetX, });
y: element.y + offsetY,
},
false,
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
@@ -4355,8 +4346,6 @@ class App extends React.Component<AppProps, AppState> {
), ),
}); });
this.scene.triggerUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ENTER) { } else if (event.key === KEYS.ENTER) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
@@ -8331,7 +8320,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
this.setState({ snapLines }); flushSync(() => {
this.setState({ snapLines });
});
// when we're editing the name of a frame, we want the user to be // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
@@ -8597,7 +8588,6 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false,
) )
: []; : [];
@@ -9026,7 +9016,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
newElement, newElement,
this.state,
), ),
); );
} }
@@ -9144,7 +9133,6 @@ class App extends React.Component<AppProps, AppState> {
nextElements, nextElements,
elementsToAdd, elementsToAdd,
topLayerFrame, topLayerFrame,
this.state,
); );
} else if (!topLayerFrame) { } else if (!topLayerFrame) {
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -219,7 +219,6 @@ const LayerUI = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Island> </Island>
</Section> </Section>
+20 -44
View File
@@ -1,10 +1,4 @@
import React, { import React, { useState, useCallback, useMemo, useRef } from "react";
useState,
useCallback,
useMemo,
useRef,
useEffect,
} from "react";
import type Library from "../data/library"; import type Library from "../data/library";
import { import {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
@@ -156,40 +150,27 @@ const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const create = useCallback( const create = () =>
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) => getSelectedElements(elements, appState, {
getSelectedElements(elements, appState, { includeBoundTextElement: true,
includeBoundTextElement: true, includeElementsInFrames: true,
includeElementsInFrames: true, });
}), const val = useRef(create());
[],
);
const val = useRef(create(appState, elements));
const prevAppState = useRef<UIAppState>(appState); const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements); const prevElements = useRef(elements);
const update = useCallback(() => { if (
if ( !isShallowEqual(
!isShallowEqual( appState.selectedElementIds,
appState.selectedElementIds, prevAppState.current.selectedElementIds,
prevAppState.current.selectedElementIds, ) ||
) || !isShallowEqual(elements, prevElements.current)
!isShallowEqual(elements, prevElements.current) ) {
) { val.current = create();
val.current = create(appState, elements); prevAppState.current = appState;
prevAppState.current = appState; prevElements.current = elements;
prevElements.current = elements; }
} return val.current;
}, [create, appState, elements]);
return useMemo(
() => ({
update,
value: val.current,
}),
[update, val],
);
}; };
/** /**
@@ -200,7 +181,6 @@ export const LibraryMenu = () => {
const { library, id, onInsertElements } = useApp(); const { library, id, onInsertElements } = useApp();
const appProps = useAppProps(); const appProps = useAppProps();
const appState = useUIAppState(); const appState = useUIAppState();
const app = useApp();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
@@ -223,13 +203,9 @@ export const LibraryMenu = () => {
}); });
}, [setAppState]); }, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return ( return (
<LibraryMenuContent <LibraryMenuContent
pendingElements={pendingElements.value} pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
@@ -63,6 +63,27 @@
max-width: 100%; max-width: 100%;
} }
.library-unit__checkbox-container,
.library-unit__checkbox-container:hover,
.library-unit__checkbox-container:active {
align-items: center;
background: none;
border: none;
color: var(--icon-fill-color);
display: flex;
justify-content: center;
margin: 0;
padding: 0.5rem;
position: absolute;
left: 2rem;
bottom: 2rem;
cursor: pointer;
input {
cursor: pointer;
}
}
.library-unit__checkbox { .library-unit__checkbox {
position: absolute; position: absolute;
top: 0.125rem; top: 0.125rem;
@@ -70,7 +91,7 @@
margin: 0; margin: 0;
.Checkbox-box { .Checkbox-box {
margin: 0 !important; margin: 0;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
border-radius: 4px; border-radius: 4px;
@@ -179,7 +179,6 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Section> </Section>
) : null} ) : null}
-59
View File
@@ -1,59 +0,0 @@
@import "../css/variables.module.scss";
.excalidraw {
--Range-track-background: var(--button-bg);
--Range-track-background-active: var(--color-primary);
--Range-thumb-background: var(--color-on-surface);
--Range-legend-color: var(--text-primary-color);
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
}
.range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--Range-track-background);
border-radius: 2px;
outline: none;
}
.range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: var(--Range-thumb-background);
border-radius: 50%;
cursor: pointer;
border: none;
}
.range-input::-moz-range-thumb {
width: 20px;
height: 20px;
background: var(--Range-thumb-background);
border-radius: 50%;
cursor: pointer;
border: none;
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
color: var(--Range-legend-color);
}
.zero-label {
position: absolute;
bottom: 0;
left: 4px;
font-size: 12px;
color: var(--Range-legend-color);
}
}
-65
View File
@@ -1,65 +0,0 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
</label>
);
};
@@ -31,7 +31,6 @@ import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement"; import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math"; import { round } from "../../../math";
import { frameAndChildrenSelectedTogether } from "../../frame";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -171,10 +170,6 @@ export const StatsInner = memo(
return getAtomicUnits(selectedElements, appState); return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]); }, [selectedElements, appState]);
const _frameAndChildrenSelectedTogether = useMemo(() => {
return frameAndChildrenSelectedTogether(selectedElements);
}, [selectedElements]);
return ( return (
<div className="exc-stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
@@ -231,7 +226,7 @@ export const StatsInner = memo(
{renderCustomStats?.(elements, appState)} {renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && ( {selectedElements.length > 0 && (
<div <div
id="elementStats" id="elementStats"
style={{ style={{
+129 -135
View File
@@ -55,152 +55,146 @@ type ToolButtonProps =
onPointerDown?(data: { pointerType: PointerType }): void; onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef( export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
( const { id: excalId } = useExcalidrawContainer();
{ const innerRef = React.useRef(null);
size = "medium", React.useImperativeHandle(ref, () => innerRef.current);
visible = true, const sizeCn = `ToolIcon_size_${props.size}`;
className = "",
...props
}: ToolButtonProps,
ref,
) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => { const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event); const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else { } else {
console.warn(error); console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsLoading(false); setIsLoading(false);
}
} }
} }
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
} }
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<label <button
className={clsx("ToolIcon", className)} className={clsx(
"ToolIcon_type_button",
sizeCn,
props.className,
props.visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title} title={props.title}
onPointerDown={(event) => { aria-label={props["aria-label"]}
lastPointerTypeRef.current = event.pointerType || null; type={type}
props.onPointerDown?.({ pointerType: event.pointerType || null }); onClick={onClick}
}} ref={innerRef}
onPointerUp={() => { disabled={isLoading || props.isLoading || !!props.disabled}
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
> >
<input {(props.icon || props.label) && (
className={`ToolIcon_type_radio ${sizeCn}`} <div
type="radio" className="ToolIcon__icon"
name={props.name} aria-hidden="true"
aria-label={props["aria-label"]} aria-disabled={!!props.disabled}
aria-keyshortcuts={props["aria-keyshortcuts"]} >
data-testid={props["data-testid"]} {props.icon || props.label}
id={`${excalId}-${props.id}`} {props.keyBindingLabel && (
onChange={() => { <span className="ToolIcon__keybinding">
props.onChange?.({ pointerType: lastPointerTypeRef.current }); {props.keyBindingLabel}
}} </span>
checked={props.checked} )}
ref={innerRef} {props.isLoading && <Spinner />}
/> </div>
<div className="ToolIcon__icon"> )}
{props.icon} {props.showAriaLabel && (
{props.keyBindingLabel && ( <div className="ToolIcon__label">
<span className="ToolIcon__keybinding"> {props["aria-label"]} {isLoading && <Spinner />}
{props.keyBindingLabel} </div>
</span> )}
)} {props.children}
</div> </button>
</label>
); );
}, }
);
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
)}
</div>
</label>
);
});
ToolButton.defaultProps = {
visible: true,
className: "",
size: "medium",
};
ToolButton.displayName = "ToolButton"; ToolButton.displayName = "ToolButton";
@@ -171,17 +171,15 @@ export const Hyperlink = ({
}, [handleSubmit]); }, [handleSubmit]);
useEffect(() => { useEffect(() => {
let timeoutId: number | null = null;
if ( if (
isEditing && inputRef &&
inputRef?.current && inputRef.current &&
!(device.viewport.isMobile || device.isTouchScreen) !(device.viewport.isMobile || device.isTouchScreen)
) { ) {
inputRef.current.select(); inputRef.current.select();
} }
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (isEditing) { if (isEditing) {
@@ -209,7 +207,15 @@ export const Hyperlink = ({
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [appState, element, isEditing, setAppState, elementsMap]); }, [
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
@@ -1,6 +1,6 @@
.excalidraw { .excalidraw {
.excalifont { .excalifont {
font-family: "Excalifont", "Xiaolai"; font-family: "Excalifont";
} }
// WelcomeSreen common // WelcomeSreen common
-3
View File
@@ -32,7 +32,6 @@
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05); 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--button-bg: var(--color-surface-mid);
--button-hover-bg: var(--color-surface-high); --button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high); --button-active-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active); --button-active-border: var(--color-brand-active);
@@ -172,8 +171,6 @@
--button-destructive-bg-color: #5a0000; --button-destructive-bg-color: #5a0000;
--button-destructive-color: #{$oc-red-3}; --button-destructive-color: #{$oc-red-3};
--button-bg: var(--color-surface-high);
--button-gray-1: #363636; --button-gray-1: #363636;
--button-gray-2: #272727; --button-gray-2: #272727;
--button-gray-3: #222; --button-gray-3: #222;
+41 -14
View File
@@ -22,7 +22,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]); mimeTypes.push(MIME_TYPES[type]);
return mimeTypes; return mimeTypes;
}, [] as string[]); }, [] as string[]);
@@ -39,34 +38,62 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => { legacySetup: (resolve, reject, input) => {
let isResolved = false;
let checkInterval: number | null = null;
// Increased delay for iOS to ensure file selection is complete
const CHECK_INTERVAL = 100; // 100ms
const MAX_CHECKS = 50; // 5 seconds total
let checkCount = 0;
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS); const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => { const checkForFile = () => {
// this hack might not work when expecting multiple files if (isResolved) {
return;
}
if (input.files?.length) { if (input.files?.length) {
isResolved = true;
const ret = opts.multiple ? [...input.files] : input.files[0]; const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType); resolve(ret as RetType);
return true;
} }
checkCount++;
if (checkCount >= MAX_CHECKS) {
scheduleRejection();
return true;
}
return false;
}; };
const focusHandler = () => {
// Start checking for files only after focus event
checkInterval = window.setInterval(() => {
if (checkForFile()) {
clearInterval(checkInterval!);
}
}, CHECK_INTERVAL);
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
};
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler); window.addEventListener(EVENT.FOCUS, focusHandler);
}); });
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => { return (rejectPromise) => {
clearInterval(interval); if (checkInterval) {
clearInterval(checkInterval);
}
scheduleRejection.cancel(); scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler); window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection); document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this if (rejectPromise && !isResolved) {
console.warn("Opening the file was canceled (legacy-fs)."); console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError()); rejectPromise(new AbortError());
} }
-105
View File
@@ -1,105 +0,0 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});
+12 -34
View File
@@ -36,18 +36,7 @@ import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url"; import { toValidURL } from "./url";
/** const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
* format: hostname or hostname/pathname
*
* Both hostname and pathname are matched partially,
* hostname from the end, pathname from the start, with subdomain/path
* boundaries
**/
const ALLOWED_LIBRARY_URLS = [
"excalidraw.com",
// when installing from github PRs
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -480,37 +469,26 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; return resElements;
}; };
export const validateLibraryUrl = ( const validateLibraryUrl = (
libraryUrl: string, libraryUrl: string,
/** /**
* @returns `true` if the URL is valid, throws otherwise. * If supplied, takes precedence over the default whitelist.
* Return `true` if the URL is valid.
*/ */
validator: validator?: (libraryUrl: string) => boolean,
| ((libraryUrl: string) => boolean) ): boolean => {
| string[] = ALLOWED_LIBRARY_URLS,
): true => {
if ( if (
typeof validator === "function" validator
? validator(libraryUrl) ? validator(libraryUrl)
: validator.some((allowedUrlDef) => { : ALLOWED_LIBRARY_HOSTNAMES.includes(
const allowedUrl = new URL( new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
`https://${allowedUrlDef.replace(/^https?:\/\//, "")}`, )
);
const { hostname, pathname } = new URL(libraryUrl);
return (
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
new RegExp(
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
).test(pathname)
);
})
) { ) {
return true; return true;
} }
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`); console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
}; };
export const parseLibraryTokensFromUrl = () => { export const parseLibraryTokensFromUrl = () => {
+2 -2
View File
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]"); expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>"); expect(normalizeLink("<test>")).toBe("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&"); expect(normalizeLink("test&")).toBe("test&amp;");
}); });
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { escapeDoubleQuotes } from "../utils"; import { sanitizeHTMLAttribute } from "../utils";
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
if (!link) { if (!link) {
return link; return link;
} }
return sanitizeUrl(escapeDoubleQuotes(link)); return sanitizeUrl(sanitizeHTMLAttribute(link));
}; };
export const isLocalLink = (link: string | null) => { export const isLocalLink = (link: string | null) => {
-14
View File
@@ -909,20 +909,6 @@ export const updateElbowArrowPoints = (
); );
} }
// 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && updates.points) {
return normalizeArrowElementUpdate(
updates.points.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
const updatedPoints: readonly LocalPoint[] = updates.points const updatedPoints: readonly LocalPoint[] = updates.points
? updates.points && updates.points.length === 2 ? updates.points && updates.points.length === 2
? arrow.points.map((p, idx) => ? arrow.points.map((p, idx) =>
+8 -4
View File
@@ -1,7 +1,11 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils"; import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -208,7 +212,7 @@ export const getEmbedLink = (
// Note that we don't attempt to parse the username as it can consist of // Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything // non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed. // without affecting the embed.
const safeURL = escapeDoubleQuotes( const safeURL = sanitizeHTMLAttribute(
`https://twitter.com/x/status/${postId}`, `https://twitter.com/x/status/${postId}`,
); );
@@ -227,7 +231,7 @@ export const getEmbedLink = (
if (RE_REDDIT.test(link)) { if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!; const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = escapeDoubleQuotes( const safeURL = sanitizeHTMLAttribute(
`https://reddit.com/r/${page}/comments/${postId}/${title}`, `https://reddit.com/r/${page}/comments/${postId}/${title}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
@@ -245,7 +249,7 @@ export const getEmbedLink = (
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = escapeDoubleQuotes( const safeURL = sanitizeHTMLAttribute(
`https://gist.github.com/${user}/${gistId}`, `https://gist.github.com/${user}/${gistId}`,
); );
const ret: IframeDataWithSandbox = { const ret: IframeDataWithSandbox = {
+4 -12
View File
@@ -254,9 +254,6 @@ const addNewNode = (
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth, strokeWidth: element.strokeWidth,
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
}); });
invariant( invariant(
@@ -332,9 +329,6 @@ export const addNewNodes = (
backgroundColor: startNode.backgroundColor, backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor, strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth, strokeWidth: startNode.strokeWidth,
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
}); });
invariant( invariant(
@@ -422,13 +416,11 @@ const createBindingArrow = (
type: "arrow", type: "arrow",
x: startX, x: startX,
y: startY, y: startY,
startArrowhead: null, startArrowhead: appState.currentItemStartArrowhead,
endArrowhead: appState.currentItemEndArrowhead, endArrowhead: appState.currentItemEndArrowhead,
strokeColor: startBindingElement.strokeColor, strokeColor: appState.currentItemStrokeColor,
strokeStyle: startBindingElement.strokeStyle, strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: startBindingElement.strokeWidth, strokeWidth: appState.currentItemStrokeWidth,
opacity: startBindingElement.opacity,
roughness: startBindingElement.roughness,
points: [pointFrom(0, 0), pointFrom(endX, endY)], points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true, elbowed: true,
}); });
+35 -9
View File
@@ -3,6 +3,7 @@ import {
FONT_FAMILY_FALLBACKS, FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT, CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks, getFontFamilyFallbacks,
} from "../constants"; } from "../constants";
import { isTextElement } from "../element"; import { isTextElement } from "../element";
@@ -136,28 +137,50 @@ export class Fonts {
/** /**
* Load font faces for a given scene and trigger scene update. * Load font faces for a given scene and trigger scene update.
*
* FontFaceSet loadingdone event we listen on may not always
* fire (looking at you Safari), so on init we manually load all
* fonts and rerender scene text elements once done.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/ */
public loadSceneFonts = async (): Promise<FontFace[]> => { public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies(); const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = Fonts.getCharsPerFamily( const charsPerFamily = isSafari
this.scene.getNonDeletedElements(), ? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
); : undefined;
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
}; };
/** /**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export). * Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/ */
public static loadElementsFonts = async ( public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => { ): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements); const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = Fonts.getCharsPerFamily(elements); const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(elements)
: undefined;
return Fonts.loadFontFaces(fontFamilies, charsPerFamily); return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
}; };
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/** /**
* Generate CSS @font-face declarations for the given elements. * Generate CSS @font-face declarations for the given elements.
*/ */
@@ -200,7 +223,7 @@ export class Fonts {
private static async loadFontFaces( private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily: Record<number, Set<string>>, charsPerFamily?: Record<number, Set<string>>,
) { ) {
// add all registered font faces into the `document.fonts` (if not added already) // add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) { for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -225,7 +248,7 @@ export class Fonts {
private static *fontFacesLoader( private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>, fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily: Record<number, Set<string>>, charsPerFamily?: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> { ): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) { for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({ const font = getFontString({
@@ -233,9 +256,12 @@ export class Fonts {
fontSize: 16, fontSize: 16,
}); });
// WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face! // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded // for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
const text = Fonts.getCharacters(charsPerFamily, fontFamily); const text =
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
if (!window.document.fonts.check(font, text)) { if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => { yield promiseTry(async () => {
+43 -213
View File
@@ -95,11 +95,12 @@ export const getElementsCompletelyInFrame = (
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return getElementsWithinSelection([frame], element, elementsMap).some( return getElementsWithinSelection(elements, element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
}; };
@@ -143,7 +144,7 @@ export const elementOverlapsWithFrame = (
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame(element, frame, elementsMap) isElementContainingFrame([frame], element, frame, elementsMap)
); );
}; };
@@ -282,7 +283,7 @@ export const getElementsInResizingFrame = (
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...getElementsCompletelyInFrame(allElements, frame, elementsMap),
...prevElementsInFrame.filter((element) => ...prevElementsInFrame.filter((element) =>
isElementContainingFrame(element, frame, elementsMap), isElementContainingFrame(allElements, element, frame, elementsMap),
), ),
]); ]);
@@ -369,57 +370,12 @@ export const getElementsInNewFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return omitPartialGroups( return omitGroupsContainingFrameLikes(
omitGroupsContainingFrameLikes( elements,
elements, getElementsCompletelyInFrame(elements, frame, elementsMap),
getElementsCompletelyInFrame(elements, frame, elementsMap),
),
frame,
elementsMap,
); );
}; };
export const omitPartialGroups = (
elements: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
allElementsMap: ElementsMap,
) => {
const elementsToReturn = [];
const checkedGroups = new Map<string, boolean>();
for (const element of elements) {
let shouldOmit = false;
if (element.groupIds.length > 0) {
// if some partial group should be omitted, then all elements in that group should be omitted
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
shouldOmit = true;
} else {
const allElementsInGroup = new Set(
element.groupIds.flatMap((gid) =>
getElementsInGroup(allElementsMap, gid),
),
);
shouldOmit = !elementsAreInFrameBounds(
Array.from(allElementsInGroup),
frame,
allElementsMap,
);
}
element.groupIds.forEach((gid) => {
checkedGroups.set(gid, shouldOmit);
});
}
if (!shouldOmit) {
elementsToReturn.push(element);
}
}
return elementsToReturn;
};
export const getContainingFrame = ( export const getContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -498,7 +454,6 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>(); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
@@ -534,17 +489,6 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element); finalElementsToAdd.push(element);
} }
@@ -633,7 +577,6 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -740,16 +683,6 @@ export const getTargetFrame = (
? getContainerElement(element, elementsMap) || element ? getContainerElement(element, elementsMap) || element
: element; : element;
// if the element and its containing frame are both selected, then
// the containing frame is the target frame
if (
_element.frameId &&
appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[_element.frameId]
) {
return getContainingFrame(_element, elementsMap);
}
return appState.selectedElementIds[_element.id] && return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged appState.selectedElementsAreBeingDragged
? appState.frameToHighlight ? appState.frameToHighlight
@@ -762,151 +695,61 @@ export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElementsMap: ElementsMap, allElementsMap: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameLikeElement;
checkedGroups?: Map<string, boolean>;
},
) => { ) => {
const frame = const frame = getTargetFrame(element, allElementsMap, appState);
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
if (!frame) {
return false;
}
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element, allElementsMap) || element ? getContainerElement(element, allElementsMap) || element
: element; : element;
const setGroupsInFrame = (isInFrame: boolean) => { if (frame) {
if (opts?.checkedGroups) { // Perf improvement:
_element.groupIds.forEach((groupId) => { // For an element that's already in a frame, if it's not being dragged
opts.checkedGroups?.set(groupId, isInFrame); // then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
}); // It has to be in its containing frame.
if (
!appState.selectedElementIds[element.id] ||
!appState.selectedElementsAreBeingDragged
) {
return true;
} }
};
if ( if (_element.groupIds.length === 0) {
// if the element is not selected, or it is selected but not being dragged, return elementOverlapsWithFrame(_element, frame, allElementsMap);
// frame membership won't update, so return true
!appState.selectedElementIds[_element.id] ||
!appState.selectedElementsAreBeingDragged ||
// if both frame and element are selected, won't update membership, so return true
(appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[frame.id])
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame, allElementsMap);
}
for (const gid of _element.groupIds) {
if (opts?.checkedGroups?.has(gid)) {
return opts.checkedGroups.get(gid)!!;
} }
}
const allElementsInGroup = new Set( const allElementsInGroup = new Set(
_element.groupIds _element.groupIds.flatMap((gid) =>
.filter((gid) => { getElementsInGroup(allElementsMap, gid),
if (opts?.checkedGroups) { ),
return !opts.checkedGroups.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElementsMap, appState),
); );
const editingGroupOverlapsFrame = appState.frameToHighlight !== null; if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElementsMap, appState),
);
if (editingGroupOverlapsFrame) { const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
return true;
}
selectedElements.forEach((selectedElement) => { if (editingGroupOverlapsFrame) {
allElementsInGroup.delete(selectedElement); return true;
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameLikeElement(elementInGroup)) {
setGroupsInFrame(false);
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
setGroupsInFrame(true);
return true;
}
}
return false;
};
export const shouldApplyFrameClip = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
checkedGroups?: Map<string, boolean>,
) => {
if (!appState.frameRendering || !appState.frameRendering.clip) {
return false;
}
// for individual elements, only clip when the element is
// a. overlapping with the frame, or
// b. containing the frame, for example when an element is used as a background
// and is therefore bigger than the frame and completely contains the frame
const shouldClipElementItself =
isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame(element, frame, elementsMap);
if (shouldClipElementItself) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
}
return true;
}
// if an element is outside the frame, but is part of a group that has some elements
// "in" the frame, we should clip the element
if (
!shouldClipElementItself &&
element.groupIds.length > 0 &&
!elementsAreInFrameBounds([element], frame, elementsMap)
) {
let shouldClip = false;
// if no elements are being dragged, we can skip the geometry check
// because we know if the element is in the given frame or not
if (!appState.selectedElementsAreBeingDragged) {
shouldClip = element.frameId === frame.id;
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, shouldClip);
} }
} else {
shouldClip = isElementInFrame(element, elementsMap, appState, { selectedElements.forEach((selectedElement) => {
targetFrame: frame, allElementsInGroup.delete(selectedElement);
checkedGroups,
}); });
} }
for (const groupId of element.groupIds) { for (const elementInGroup of allElementsInGroup) {
checkedGroups?.set(groupId, shouldClip); if (isFrameLikeElement(elementInGroup)) {
return false;
}
} }
return shouldClip; for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
return true;
}
}
} }
return false; return false;
@@ -936,16 +779,3 @@ export const getElementsOverlappingFrame = (
.filter((el) => !el.frameId || el.frameId === frame.id) .filter((el) => !el.frameId || el.frameId === frame.id)
); );
}; };
export const frameAndChildrenSelectedTogether = (
selectedElements: readonly ExcalidrawElement[],
) => {
const selectedElementsMap = arrayToMap(selectedElements);
return (
selectedElements.length > 1 &&
selectedElements.some(
(element) => element.frameId && selectedElementsMap.has(element.frameId),
)
);
};
+5 -23
View File
@@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getTargetFrame, getTargetFrame,
shouldApplyFrameClip, isElementInFrame,
} from "../frame"; } from "../frame";
import { import {
isEmbeddableElement, isEmbeddableElement,
@@ -273,8 +273,6 @@ const _renderStaticScene = ({
} }
}); });
const inFrameGroupsMap = new Map<string, boolean>();
// Paint visible elements // Paint visible elements
visibleElements visibleElements
.filter((el) => !isIframeLikeElement(el)) .filter((el) => !isIframeLikeElement(el))
@@ -299,16 +297,9 @@ const _renderStaticScene = ({
appState.frameRendering.clip appState.frameRendering.clip
) { ) {
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if (
frame && // TODO do we need to check isElementInFrame here?
shouldApplyFrameClip( if (frame && isElementInFrame(element, elementsMap, appState)) {
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
renderElement( renderElement(
@@ -409,16 +400,7 @@ const _renderStaticScene = ({
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if ( if (frame && isElementInFrame(element, elementsMap, appState)) {
frame &&
shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
render(); render();
+2 -5
View File
@@ -183,12 +183,10 @@ export const getSelectedElements = (
includeElementsInFrames?: boolean; includeElementsInFrames?: boolean;
}, },
) => { ) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
const selectedElements: ExcalidrawElement[] = []; const selectedElements: ExcalidrawElement[] = [];
for (const element of elements.values()) { for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
if ( if (
@@ -197,7 +195,6 @@ export const getSelectedElements = (
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
} }
@@ -206,8 +203,8 @@ export const getSelectedElements = (
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach( getFrameChildren(elements, element.id).forEach((e) =>
(e) => !addedElements.has(e.id) && elementsToInclude.push(e), elementsToInclude.push(e),
); );
} }
elementsToInclude.push(element); elementsToInclude.push(element);
@@ -10896,7 +10896,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10904,7 +10909,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 126, "height": 126,
"id": "KPrBI4g_v9qUB1XxYLgSz", "id": "KPrBI4g_v9qUB1XxYLgSz",
"index": "a0", "index": "a0",
"isDeleted": true, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10917,7 +10922,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 6,
"width": 157, "width": 157,
"x": 600, "x": 600,
"y": 0, "y": 0,
@@ -10928,7 +10933,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10936,7 +10946,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 129, "height": 129,
"id": "u2JGnnmoJ0VATV4vCNJE5", "id": "u2JGnnmoJ0VATV4vCNJE5",
"index": "a1", "index": "a1",
"isDeleted": true, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10949,7 +10959,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 4, "version": 6,
"width": 124, "width": 124,
"x": 1152, "x": 1152,
"y": 516, "y": 516,
@@ -10973,15 +10983,15 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00161", "focus": "-0.00161",
"gap": "3.53708", "gap": "3.53708",
}, },
"endIsSpecial": false, "endIsSpecial": null,
"fillStyle": "solid", "fillStyle": "solid",
"fixedSegments": [], "fixedSegments": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "236.10000", "height": "448.10100",
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
"index": "a2", "index": "a2",
"isDeleted": true, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
@@ -10992,12 +11002,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
0, 0,
], ],
[ [
"178.90000", "225.95000",
0, 0,
], ],
[ [
"178.90000", "225.95000",
"236.10000", "448.10100",
],
[
"451.90000",
"448.10100",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -11014,16 +11028,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00159", "focus": "-0.00159",
"gap": 5, "gap": 5,
}, },
"startIsSpecial": false, "startIsSpecial": null,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 3, "version": 6,
"width": "178.90000", "width": "451.90000",
"x": 1035, "x": 762,
"y": "274.90000", "y": "62.90000",
} }
`; `;
@@ -11035,7 +11049,8 @@ History {
[Function], [Function],
], ],
}, },
"redoStack": [ "redoStack": [],
"undoStack": [
HistoryEntry { HistoryEntry {
"appStateChange": AppStateChange { "appStateChange": AppStateChange {
"delta": Delta { "delta": Delta {
@@ -11044,12 +11059,86 @@ History {
}, },
}, },
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map { "added": Map {},
"6Rm4g567UQM4WjLwej2Vc" => Delta { "removed": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": { "deleted": {
"isDeleted": true, "angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 873,
"y": 212,
}, },
"inserted": { "inserted": {
"isDeleted": true,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
},
"inserted": {
"isDeleted": true,
},
},
},
"updated": Map {},
},
},
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"6Rm4g567UQM4WjLwej2Vc" => Delta {
"deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
@@ -11114,15 +11203,14 @@ History {
"x": 1035, "x": 1035,
"y": "274.90000", "y": "274.90000",
}, },
"inserted": {
"isDeleted": true,
},
}, },
}, },
"removed": Map {},
"updated": Map { "updated": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta { "KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11130,12 +11218,12 @@ History {
}, },
], ],
}, },
"inserted": {
"boundElements": [],
},
}, },
"u2JGnnmoJ0VATV4vCNJE5" => Delta { "u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11143,88 +11231,14 @@ History {
}, },
], ],
}, },
},
},
},
},
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": { "inserted": {
"angle": 0, "boundElements": [],
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 600,
"y": 0,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
}, },
}, },
}, },
"removed": Map {},
"updated": Map {},
}, },
}, },
], ],
"undoStack": [],
} }
`; `;
@@ -233,7 +233,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"versionNonce": 1051383431, "versionNonce": 1996028265,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": 50, "y": 50,
@@ -50,7 +50,7 @@ describe("actionStyles", () => {
// Roughness // Roughness
fireEvent.click(screen.getByTitle("Cartoonist")); fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity // Opacity
fireEvent.change(screen.getByTestId("opacity"), { fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" }, target: { value: "60" },
}); });
@@ -338,7 +338,7 @@ describe("contextMenu element", () => {
// Roughness // Roughness
fireEvent.click(screen.getByTitle("Cartoonist")); fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity // Opacity
fireEvent.change(screen.getByTestId("opacity"), { fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" }, target: { value: "60" },
}); });
+5 -4
View File
@@ -2077,15 +2077,16 @@ describe("history", () => {
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
Keyboard.undo(); Keyboard.redo();
const modifiedArrow = h.elements.filter( const modifiedArrow = h.elements.filter(
(el) => el.type === "arrow", (el) => el.type === "arrow",
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toCloselyEqualPoints([ expect(modifiedArrow.points).toEqual([
[0, 0], [0, 0],
[178.9, 0], [225.95000000000005, 0],
[178.9, 236.1], [225.95000000000005, 448.10100010002003],
[451.9000000000001, 448.10100010002003],
]); ]);
}); });
+7 -1
View File
@@ -1,4 +1,4 @@
import { isTransparent } from "../utils"; import { isTransparent, sanitizeHTMLAttribute } from "../utils";
describe("Test isTransparent", () => { describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => { it("should return true when color is rgb transparent", () => {
@@ -11,3 +11,9 @@ describe("Test isTransparent", () => {
expect(isTransparent("#ced4da")).toEqual(false); expect(isTransparent("#ced4da")).toEqual(false);
}); });
}); });
describe("sanitizeHTMLAttribute()", () => {
it("should escape HTML attribute special characters & not double escape", () => {
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&amp;&quot;&#39;&gt;&lt;");
});
});
-2
View File
@@ -681,8 +681,6 @@ export type AppClassProperties = {
getEditorUIOffsets: App["getEditorUIOffsets"]; getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"]; visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"]; excalidrawContainerValue: App["excalidrawContainerValue"];
onPointerUpEmitter: App["onPointerUpEmitter"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
+11 -6
View File
@@ -1226,10 +1226,15 @@ export class PromisePool<T> {
} }
} }
/** export const sanitizeHTMLAttribute = (html: string) => {
* use when you need to render unsafe string as HTML attribute, but MAKE SURE return (
* the attribute is double-quoted when constructing the HTML string html
*/ // note, if we're not doing stupid things, escaping " is enough,
export const escapeDoubleQuotes = (str: string) => { // but we might end up doing stupid things
return str.replace(/"/g, "&quot;"); .replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
}; };
Binary file not shown.
Binary file not shown.
+11 -29
View File
@@ -123,17 +123,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
"./assets/NotoEmoji-Regular-2048.ttf", "./assets/NotoEmoji-Regular-2048.ttf",
); );
const liberationPath = path.resolve(
__dirname,
"./assets/LiberationSans-Regular.ttf",
);
// need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
const liberationPath_2048 = path.resolve(
__dirname,
"./assets/LiberationSans-Regular-2048.ttf",
);
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), { const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
type: "ttf", type: "ttf",
}); });
@@ -141,10 +130,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
type: "ttf", type: "ttf",
}); });
const liberationFont = Font.create(fs.readFileSync(liberationPath), {
type: "ttf",
});
const sortedFonts = Array.from(fonts.entries()).sort( const sortedFonts = Array.from(fonts.entries()).sort(
([family1], [family2]) => (family1 > family2 ? 1 : -1), ([family1], [family2]) => (family1 > family2 ? 1 : -1),
); );
@@ -156,6 +141,13 @@ module.exports.woff2ServerPlugin = (options = {}) => {
continue; continue;
} }
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
const baseFont = Regular[0]; const baseFont = Regular[0];
const tempPaths = Regular.map((_, index) => const tempPaths = Regular.map((_, index) =>
path.resolve(outputDir, `temp_${family}_${index}.ttf`), path.resolve(outputDir, `temp_${family}_${index}.ttf`),
@@ -173,18 +165,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const mergedFontPath = path.resolve(outputDir, `${family}.ttf`); const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
// add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
if (baseFont.data.head.unitsPerEm === 2048) { if (baseFont.data.head.unitsPerEm === 2048) {
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048); fallbackFontsPaths.push(emojiPath_2048);
} else { } else {
fallbackFontsPaths.push(emojiPath, liberationPath); fallbackFontsPaths.push(emojiPath);
} }
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts // drop Vertical related metrics, otherwise it does not allow us to merge the fonts
@@ -212,12 +196,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const base = baseFont.data.name[field]; const base = baseFont.data.name[field];
const xiaolai = xiaolaiFont.data.name[field]; const xiaolai = xiaolaiFont.data.name[field];
const emoji = emojiFont.data.name[field]; const emoji = emojiFont.data.name[field];
const liberation = liberationFont.data.name[field];
// liberation font
return shouldIncludeXiaolaiFallback return shouldIncludeXiaolaiFallback
? `${base} & ${xiaolai} & ${emoji} & ${liberation}` ? `${base} & ${xiaolai} & ${emoji}`
: `${base} & ${emoji} & ${liberation}`; : `${base} & ${emoji}`;
}; };
mergedFont.set({ mergedFont.set({