mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
19 Commits
zsviczian-
...
are/librar
Author | SHA1 | Date | |
---|---|---|---|
b51f6d178c | |||
84bab403ff | |||
61e0bb83d0 | |||
bd1590fc74 | |||
d29c3db7f6 | |||
a58822c1c1 | |||
a3e1619635 | |||
52eaf64591 | |||
7028daa44a | |||
65f218b100 | |||
807b3c59f2 | |||
b8da5065fd | |||
49f1276ef2 | |||
8f20b29b73 | |||
f87c2cde09 | |||
0bf234fcc9 | |||
dd1b45a25a | |||
ec06fbc1fc | |||
fa05ae1230 |
@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -48,6 +46,7 @@ const alignSelectedElements = (
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
@ -64,7 +63,8 @@ export const actionAlignTop = register({
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -79,7 +79,7 @@ export const actionAlignTop = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -97,7 +97,8 @@ export const actionAlignBottom = register({
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -112,7 +113,7 @@ export const actionAlignBottom = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -130,7 +131,8 @@ export const actionAlignLeft = register({
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -145,7 +147,7 @@ export const actionAlignLeft = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -163,7 +165,8 @@ export const actionAlignRight = register({
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -178,7 +181,7 @@ export const actionAlignRight = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -12,6 +12,8 @@ import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { newFrameElement } from "../element/newElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
@ -174,10 +176,31 @@ export const actionWrapSelectionInFrame = register({
|
||||
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(
|
||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||
selectedElements,
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -25,8 +25,10 @@ import type {
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
frameAndChildrenSelectedTogether,
|
||||
getElementsInResizingFrame,
|
||||
getFrameLikeElements,
|
||||
getRootElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@ -60,8 +62,11 @@ const enableActionGroup = (
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
selectedElements.length >= 2 &&
|
||||
!allElementsInSameGroup(selectedElements) &&
|
||||
!frameAndChildrenSelectedTogether(selectedElements)
|
||||
);
|
||||
};
|
||||
|
||||
@ -71,10 +76,12 @@ export const actionGroup = register({
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElements = getRootElements(
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
}),
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
|
@ -89,6 +89,7 @@ import type {
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
@ -115,10 +116,12 @@ import {
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { Range } from "../components/Range";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@ -218,33 +221,47 @@ const changeFontSize = (
|
||||
) => {
|
||||
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 {
|
||||
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,
|
||||
),
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
// update state only if we've set all select text elements to
|
||||
@ -614,25 +631,12 @@ export const actionChangeOpacity = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<label className="control-label">
|
||||
{t("labels.opacity")}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="10"
|
||||
onChange={(event) => updateData(+event.target.value)}
|
||||
value={
|
||||
getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.opacity,
|
||||
true,
|
||||
appState.currentItemOpacity,
|
||||
) ?? undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Range
|
||||
updateData={updateData}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
testId="opacity"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
@ -20,17 +19,17 @@ export const actionSelectAll = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
const selectedElementIds = elements
|
||||
.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
)
|
||||
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
|
@ -1,8 +1,10 @@
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { updateBoundElements } from "./element/binding";
|
||||
import type Scene from "./scene/Scene";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@ -13,6 +15,7 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
@ -26,12 +29,18 @@ export const alignElements = (
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -51,6 +51,7 @@ import {
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { CLASSES } from "../constants";
|
||||
import { alignActionsPredicate } from "../actions/actionAlign";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
|
||||
@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
const showAlignActions =
|
||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||
{showAlignActions && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
|
@ -3235,7 +3235,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
|
||||
addElementsToFrame(
|
||||
nextElements,
|
||||
eligibleElements,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
@ -4326,10 +4331,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
});
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
@ -4346,6 +4355,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
});
|
||||
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ENTER) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
@ -8320,9 +8331,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
flushSync(() => {
|
||||
this.setState({ snapLines });
|
||||
});
|
||||
this.setState({ snapLines });
|
||||
|
||||
// when we're editing the name of a frame, we want the user to be
|
||||
// able to select and interact with the text input
|
||||
@ -8588,6 +8597,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements,
|
||||
this.state.selectionElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
|
||||
@ -9016,6 +9026,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElementsMapIncludingDeleted(),
|
||||
elementsInsideFrame,
|
||||
newElement,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -9133,6 +9144,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nextElements,
|
||||
elementsToAdd,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
} else if (!topLayerFrame) {
|
||||
if (this.state.editingGroupId) {
|
||||
|
@ -219,6 +219,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
|
@ -1,4 +1,10 @@
|
||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import type Library from "../data/library";
|
||||
import {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
@ -150,27 +156,40 @@ const usePendingElementsMemo = (
|
||||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const create = () =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
const val = useRef(create());
|
||||
const create = useCallback(
|
||||
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const val = useRef(create(appState, elements));
|
||||
const prevAppState = useRef<UIAppState>(appState);
|
||||
const prevElements = useRef(elements);
|
||||
|
||||
if (
|
||||
!isShallowEqual(
|
||||
appState.selectedElementIds,
|
||||
prevAppState.current.selectedElementIds,
|
||||
) ||
|
||||
!isShallowEqual(elements, prevElements.current)
|
||||
) {
|
||||
val.current = create();
|
||||
prevAppState.current = appState;
|
||||
prevElements.current = elements;
|
||||
}
|
||||
return val.current;
|
||||
const update = useCallback(() => {
|
||||
if (
|
||||
!isShallowEqual(
|
||||
appState.selectedElementIds,
|
||||
prevAppState.current.selectedElementIds,
|
||||
) ||
|
||||
!isShallowEqual(elements, prevElements.current)
|
||||
) {
|
||||
val.current = create(appState, elements);
|
||||
prevAppState.current = appState;
|
||||
prevElements.current = elements;
|
||||
}
|
||||
}, [create, appState, elements]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
update,
|
||||
value: val.current,
|
||||
}),
|
||||
[update, val],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -181,6 +200,7 @@ export const LibraryMenu = () => {
|
||||
const { library, id, onInsertElements } = useApp();
|
||||
const appProps = useAppProps();
|
||||
const appState = useUIAppState();
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
@ -203,9 +223,13 @@ export const LibraryMenu = () => {
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
useEffect(() => {
|
||||
return app.onPointerUpEmitter.on(() => pendingElements.update());
|
||||
}, [app, pendingElements]);
|
||||
|
||||
return (
|
||||
<LibraryMenuContent
|
||||
pendingElements={pendingElements}
|
||||
pendingElements={pendingElements.value}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
|
@ -63,27 +63,6 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0.125rem;
|
||||
@ -91,7 +70,7 @@
|
||||
margin: 0;
|
||||
|
||||
.Checkbox-box {
|
||||
margin: 0;
|
||||
margin: 0 !important;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 4px;
|
||||
|
@ -179,6 +179,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
59
packages/excalidraw/components/Range.scss
Normal file
59
packages/excalidraw/components/Range.scss
Normal file
@ -0,0 +1,59 @@
|
||||
@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
packages/excalidraw/components/Range.tsx
Normal file
65
packages/excalidraw/components/Range.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
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,6 +31,7 @@ import "./Stats.scss";
|
||||
import { isGridModeEnabled } from "../../snapping";
|
||||
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
||||
import { round } from "../../../math";
|
||||
import { frameAndChildrenSelectedTogether } from "../../frame";
|
||||
|
||||
interface StatsProps {
|
||||
app: AppClassProperties;
|
||||
@ -170,6 +171,10 @@ export const StatsInner = memo(
|
||||
return getAtomicUnits(selectedElements, appState);
|
||||
}, [selectedElements, appState]);
|
||||
|
||||
const _frameAndChildrenSelectedTogether = useMemo(() => {
|
||||
return frameAndChildrenSelectedTogether(selectedElements);
|
||||
}, [selectedElements]);
|
||||
|
||||
return (
|
||||
<div className="exc-stats">
|
||||
<Island padding={3}>
|
||||
@ -226,7 +231,7 @@ export const StatsInner = memo(
|
||||
{renderCustomStats?.(elements, appState)}
|
||||
</Collapsible>
|
||||
|
||||
{selectedElements.length > 0 && (
|
||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
||||
<div
|
||||
id="elementStats"
|
||||
style={{
|
||||
|
@ -55,146 +55,152 @@ type ToolButtonProps =
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
export const ToolButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
visible = true,
|
||||
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 ret = "onClick" in props && props.onClick?.(event);
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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}
|
||||
<label
|
||||
className={clsx("ToolIcon", className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
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",
|
||||
};
|
||||
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.displayName = "ToolButton";
|
||||
|
@ -171,15 +171,17 @@ export const Hyperlink = ({
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
if (
|
||||
inputRef &&
|
||||
inputRef.current &&
|
||||
isEditing &&
|
||||
inputRef?.current &&
|
||||
!(device.viewport.isMobile || device.isTouchScreen)
|
||||
) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isEditing) {
|
||||
@ -207,15 +209,7 @@ export const Hyperlink = ({
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
appState,
|
||||
element,
|
||||
isEditing,
|
||||
setAppState,
|
||||
elementsMap,
|
||||
device.viewport.isMobile,
|
||||
device.isTouchScreen,
|
||||
]);
|
||||
}, [appState, element, isEditing, setAppState, elementsMap]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
trackEvent("hyperlink", "delete");
|
||||
|
@ -1,6 +1,6 @@
|
||||
.excalidraw {
|
||||
.excalifont {
|
||||
font-family: "Excalifont";
|
||||
font-family: "Excalifont", "Xiaolai";
|
||||
}
|
||||
|
||||
// WelcomeSreen common
|
||||
|
@ -32,6 +32,7 @@
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--button-bg: var(--color-surface-mid);
|
||||
--button-hover-bg: var(--color-surface-high);
|
||||
--button-active-bg: var(--color-surface-high);
|
||||
--button-active-border: var(--color-brand-active);
|
||||
@ -171,6 +172,8 @@
|
||||
--button-destructive-bg-color: #5a0000;
|
||||
--button-destructive-color: #{$oc-red-3};
|
||||
|
||||
--button-bg: var(--color-surface-high);
|
||||
|
||||
--button-gray-1: #363636;
|
||||
--button-gray-2: #272727;
|
||||
--button-gray-3: #222;
|
||||
|
105
packages/excalidraw/data/library.test.ts
Normal file
105
packages/excalidraw/data/library.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
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();
|
||||
});
|
||||
});
|
@ -36,7 +36,18 @@ import { Queue } from "../queue";
|
||||
import { hashElementsVersion, hashString } from "../element";
|
||||
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 = {
|
||||
/** deleted library items since last onLibraryChange event */
|
||||
@ -469,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
|
||||
return resElements;
|
||||
};
|
||||
|
||||
const validateLibraryUrl = (
|
||||
export const validateLibraryUrl = (
|
||||
libraryUrl: string,
|
||||
/**
|
||||
* If supplied, takes precedence over the default whitelist.
|
||||
* Return `true` if the URL is valid.
|
||||
* @returns `true` if the URL is valid, throws otherwise.
|
||||
*/
|
||||
validator?: (libraryUrl: string) => boolean,
|
||||
): boolean => {
|
||||
validator:
|
||||
| ((libraryUrl: string) => boolean)
|
||||
| string[] = ALLOWED_LIBRARY_URLS,
|
||||
): true => {
|
||||
if (
|
||||
validator
|
||||
typeof validator === "function"
|
||||
? validator(libraryUrl)
|
||||
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||
)
|
||||
: validator.some((allowedUrlDef) => {
|
||||
const allowedUrl = new URL(
|
||||
`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;
|
||||
}
|
||||
|
||||
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
throw new Error("Invalid or disallowed library URL");
|
||||
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
};
|
||||
|
||||
export const parseLibraryTokensFromUrl = () => {
|
||||
|
@ -25,7 +25,7 @@ describe("normalizeLink", () => {
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
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("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { sanitizeHTMLAttribute } from "../utils";
|
||||
import { escapeDoubleQuotes } from "../utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
|
@ -909,6 +909,20 @@ 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
|
||||
? updates.points && updates.points.length === 2
|
||||
? arrow.points.map((p, idx) =>
|
||||
|
@ -1,11 +1,7 @@
|
||||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import {
|
||||
getFontString,
|
||||
sanitizeHTMLAttribute,
|
||||
updateActiveTool,
|
||||
} from "../utils";
|
||||
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
@ -212,7 +208,7 @@ export const getEmbedLink = (
|
||||
// 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
|
||||
// without affecting the embed.
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://twitter.com/x/status/${postId}`,
|
||||
);
|
||||
|
||||
@ -231,7 +227,7 @@ export const getEmbedLink = (
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
@ -249,7 +245,7 @@ export const getEmbedLink = (
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://gist.github.com/${user}/${gistId}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
|
@ -254,6 +254,9 @@ const addNewNode = (
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@ -329,6 +332,9 @@ export const addNewNodes = (
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@ -416,11 +422,13 @@ const createBindingArrow = (
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
startArrowhead: null,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeColor: startBindingElement.strokeColor,
|
||||
strokeStyle: startBindingElement.strokeStyle,
|
||||
strokeWidth: startBindingElement.strokeWidth,
|
||||
opacity: startBindingElement.opacity,
|
||||
roughness: startBindingElement.roughness,
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
@ -3,7 +3,6 @@ import {
|
||||
FONT_FAMILY_FALLBACKS,
|
||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
isSafari,
|
||||
getFontFamilyFallbacks,
|
||||
} from "../constants";
|
||||
import { isTextElement } from "../element";
|
||||
@ -137,50 +136,28 @@ export class Fonts {
|
||||
|
||||
/**
|
||||
* 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[]> => {
|
||||
const sceneFamilies = this.getSceneFamilies();
|
||||
const charsPerFamily = isSafari
|
||||
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||
: undefined;
|
||||
const charsPerFamily = Fonts.getCharsPerFamily(
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
|
||||
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<FontFace[]> => {
|
||||
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
||||
const charsPerFamily = isSafari
|
||||
? Fonts.getCharsPerFamily(elements)
|
||||
: undefined;
|
||||
const charsPerFamily = Fonts.getCharsPerFamily(elements);
|
||||
|
||||
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.
|
||||
*/
|
||||
@ -223,7 +200,7 @@ export class Fonts {
|
||||
|
||||
private static async loadFontFaces(
|
||||
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)
|
||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||
@ -248,7 +225,7 @@ export class Fonts {
|
||||
|
||||
private static *fontFacesLoader(
|
||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||
charsPerFamily?: Record<number, Set<string>>,
|
||||
charsPerFamily: Record<number, Set<string>>,
|
||||
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
||||
for (const [index, fontFamily] of fontFamilies.entries()) {
|
||||
const font = getFontString({
|
||||
@ -256,12 +233,9 @@ export class Fonts {
|
||||
fontSize: 16,
|
||||
});
|
||||
|
||||
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
||||
// 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 =
|
||||
isSafari && charsPerFamily
|
||||
? Fonts.getCharacters(charsPerFamily, fontFamily)
|
||||
: "";
|
||||
// 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!
|
||||
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
|
||||
const text = Fonts.getCharacters(charsPerFamily, fontFamily);
|
||||
|
||||
if (!window.document.fonts.check(font, text)) {
|
||||
yield promiseTry(async () => {
|
||||
|
@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element, elementsMap).some(
|
||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
|
||||
return (
|
||||
elementsAreInFrameBounds([element], frame, elementsMap) ||
|
||||
isElementIntersectingFrame(element, frame, elementsMap) ||
|
||||
isElementContainingFrame([frame], element, frame, elementsMap)
|
||||
isElementContainingFrame(element, frame, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
@ -283,7 +282,7 @@ export const getElementsInResizingFrame = (
|
||||
const elementsCompletelyInFrame = new Set([
|
||||
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame, elementsMap),
|
||||
isElementContainingFrame(element, frame, elementsMap),
|
||||
),
|
||||
]);
|
||||
|
||||
@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||
return omitPartialGroups(
|
||||
omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
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)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
@ -683,6 +740,16 @@ export const getTargetFrame = (
|
||||
? getContainerElement(element, elementsMap) || 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] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
@ -695,61 +762,151 @@ export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElementsMap: ElementsMap,
|
||||
appState: StaticCanvasAppState,
|
||||
opts?: {
|
||||
targetFrame?: ExcalidrawFrameLikeElement;
|
||||
checkedGroups?: Map<string, boolean>;
|
||||
},
|
||||
) => {
|
||||
const frame = getTargetFrame(element, allElementsMap, appState);
|
||||
const frame =
|
||||
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
|
||||
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element, allElementsMap) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
// Perf improvement:
|
||||
// For an element that's already in a frame, if it's not being dragged
|
||||
// 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
|
||||
) {
|
||||
const setGroupsInFrame = (isInFrame: boolean) => {
|
||||
if (opts?.checkedGroups) {
|
||||
_element.groupIds.forEach((groupId) => {
|
||||
opts.checkedGroups?.set(groupId, isInFrame);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
// if the element is not selected, or it is selected but not being dragged,
|
||||
// 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(
|
||||
_element.groupIds
|
||||
.filter((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 (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const allElementsInGroup = new Set(
|
||||
_element.groupIds.flatMap((gid) =>
|
||||
getElementsInGroup(allElementsMap, gid),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElementsMap, appState),
|
||||
);
|
||||
// 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;
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
// 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);
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
} else {
|
||||
shouldClip = isElementInFrame(element, elementsMap, appState, {
|
||||
targetFrame: frame,
|
||||
checkedGroups,
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return shouldClip;
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
|
||||
.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),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getTargetFrame,
|
||||
isElementInFrame,
|
||||
shouldApplyFrameClip,
|
||||
} from "../frame";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
@ -273,6 +273,8 @@ const _renderStaticScene = ({
|
||||
}
|
||||
});
|
||||
|
||||
const inFrameGroupsMap = new Map<string, boolean>();
|
||||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isIframeLikeElement(el))
|
||||
@ -297,9 +299,16 @@ const _renderStaticScene = ({
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(element, elementsMap, appState);
|
||||
|
||||
// TODO do we need to check isElementInFrame here?
|
||||
if (frame && isElementInFrame(element, elementsMap, appState)) {
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(
|
||||
element,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
inFrameGroupsMap,
|
||||
)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
renderElement(
|
||||
@ -400,7 +409,16 @@ const _renderStaticScene = ({
|
||||
|
||||
const frame = getTargetFrame(element, elementsMap, appState);
|
||||
|
||||
if (frame && isElementInFrame(element, elementsMap, appState)) {
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(
|
||||
element,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
inFrameGroupsMap,
|
||||
)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
render();
|
||||
|
@ -183,10 +183,12 @@ export const getSelectedElements = (
|
||||
includeElementsInFrames?: boolean;
|
||||
},
|
||||
) => {
|
||||
const addedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const selectedElements: ExcalidrawElement[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@ -195,6 +197,7 @@ export const getSelectedElements = (
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -203,8 +206,8 @@ export const getSelectedElements = (
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
getFrameChildren(elements, element.id).forEach(
|
||||
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
|
||||
);
|
||||
}
|
||||
elementsToInclude.push(element);
|
||||
|
@ -10896,12 +10896,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"boundElements": [],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -10909,7 +10904,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"height": 126,
|
||||
"id": "KPrBI4g_v9qUB1XxYLgSz",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@ -10922,7 +10917,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"width": 157,
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
@ -10933,12 +10928,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"boundElements": [],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -10946,7 +10936,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"height": 129,
|
||||
"id": "u2JGnnmoJ0VATV4vCNJE5",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@ -10959,7 +10949,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"width": 124,
|
||||
"x": 1152,
|
||||
"y": 516,
|
||||
@ -10983,15 +10973,15 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"focus": "-0.00161",
|
||||
"gap": "3.53708",
|
||||
},
|
||||
"endIsSpecial": null,
|
||||
"endIsSpecial": false,
|
||||
"fillStyle": "solid",
|
||||
"fixedSegments": null,
|
||||
"fixedSegments": [],
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "448.10100",
|
||||
"height": "236.10000",
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@ -11002,16 +10992,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
0,
|
||||
],
|
||||
[
|
||||
"225.95000",
|
||||
"178.90000",
|
||||
0,
|
||||
],
|
||||
[
|
||||
"225.95000",
|
||||
"448.10100",
|
||||
],
|
||||
[
|
||||
"451.90000",
|
||||
"448.10100",
|
||||
"178.90000",
|
||||
"236.10000",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -11028,16 +11014,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"focus": "-0.00159",
|
||||
"gap": 5,
|
||||
},
|
||||
"startIsSpecial": null,
|
||||
"startIsSpecial": false,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"width": "451.90000",
|
||||
"x": 762,
|
||||
"y": "62.90000",
|
||||
"version": 3,
|
||||
"width": "178.90000",
|
||||
"x": 1035,
|
||||
"y": "274.90000",
|
||||
}
|
||||
`;
|
||||
|
||||
@ -11049,8 +11035,7 @@ History {
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
"redoStack": [],
|
||||
"undoStack": [
|
||||
"redoStack": [
|
||||
HistoryEntry {
|
||||
"appStateChange": AppStateChange {
|
||||
"delta": Delta {
|
||||
@ -11059,86 +11044,12 @@ History {
|
||||
},
|
||||
},
|
||||
"elementsChange": ElementsChange {
|
||||
"added": Map {},
|
||||
"removed": Map {
|
||||
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
|
||||
"deleted": {
|
||||
"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": {
|
||||
"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 {
|
||||
"added": Map {
|
||||
"6Rm4g567UQM4WjLwej2Vc" => Delta {
|
||||
"deleted": {
|
||||
"isDeleted": true,
|
||||
},
|
||||
"inserted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
@ -11203,14 +11114,15 @@ History {
|
||||
"x": 1035,
|
||||
"y": "274.90000",
|
||||
},
|
||||
"inserted": {
|
||||
"isDeleted": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"removed": Map {},
|
||||
"updated": Map {
|
||||
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
|
||||
"deleted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
@ -11218,12 +11130,12 @@ History {
|
||||
},
|
||||
],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
},
|
||||
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
|
||||
"deleted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
@ -11231,14 +11143,88 @@ History {
|
||||
},
|
||||
],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
HistoryEntry {
|
||||
"appStateChange": AppStateChange {
|
||||
"delta": Delta {
|
||||
"deleted": {},
|
||||
"inserted": {},
|
||||
},
|
||||
},
|
||||
"elementsChange": ElementsChange {
|
||||
"added": Map {
|
||||
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
|
||||
"deleted": {
|
||||
"isDeleted": true,
|
||||
},
|
||||
"inserted": {
|
||||
"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": 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",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1996028265,
|
||||
"versionNonce": 1051383431,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 50,
|
||||
|
@ -50,7 +50,7 @@ describe("actionStyles", () => {
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
fireEvent.change(screen.getByTestId("opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
|
@ -338,7 +338,7 @@ describe("contextMenu element", () => {
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
fireEvent.change(screen.getByTestId("opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
|
@ -2077,16 +2077,15 @@ describe("history", () => {
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
Keyboard.undo();
|
||||
|
||||
const modifiedArrow = h.elements.filter(
|
||||
(el) => el.type === "arrow",
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
expect(modifiedArrow.points).toEqual([
|
||||
expect(modifiedArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[225.95000000000005, 0],
|
||||
[225.95000000000005, 448.10100010002003],
|
||||
[451.9000000000001, 448.10100010002003],
|
||||
[178.9, 0],
|
||||
[178.9, 236.1],
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
describe("Test isTransparent", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
@ -11,9 +11,3 @@ describe("Test isTransparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHTMLAttribute()", () => {
|
||||
it("should escape HTML attribute special characters & not double escape", () => {
|
||||
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&"'><");
|
||||
});
|
||||
});
|
||||
|
@ -681,6 +681,8 @@ export type AppClassProperties = {
|
||||
getEditorUIOffsets: App["getEditorUIOffsets"];
|
||||
visibleElements: App["visibleElements"];
|
||||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
|
@ -1226,15 +1226,10 @@ export class PromisePool<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeHTMLAttribute = (html: string) => {
|
||||
return (
|
||||
html
|
||||
// note, if we're not doing stupid things, escaping " is enough,
|
||||
// but we might end up doing stupid things
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
);
|
||||
/**
|
||||
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
|
||||
* the attribute is double-quoted when constructing the HTML string
|
||||
*/
|
||||
export const escapeDoubleQuotes = (str: string) => {
|
||||
return str.replace(/"/g, """);
|
||||
};
|
||||
|
BIN
scripts/woff2/assets/LiberationSans-Regular-2048.ttf
Normal file
BIN
scripts/woff2/assets/LiberationSans-Regular-2048.ttf
Normal file
Binary file not shown.
BIN
scripts/woff2/assets/LiberationSans-Regular.ttf
Normal file
BIN
scripts/woff2/assets/LiberationSans-Regular.ttf
Normal file
Binary file not shown.
@ -123,6 +123,17 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
||||
"./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), {
|
||||
type: "ttf",
|
||||
});
|
||||
@ -130,6 +141,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
||||
type: "ttf",
|
||||
});
|
||||
|
||||
const liberationFont = Font.create(fs.readFileSync(liberationPath), {
|
||||
type: "ttf",
|
||||
});
|
||||
|
||||
const sortedFonts = Array.from(fonts.entries()).sort(
|
||||
([family1], [family2]) => (family1 > family2 ? 1 : -1),
|
||||
);
|
||||
@ -141,13 +156,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackFontsPaths = [];
|
||||
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
|
||||
|
||||
if (shouldIncludeXiaolaiFallback) {
|
||||
fallbackFontsPaths.push(xiaolaiPath);
|
||||
}
|
||||
|
||||
const baseFont = Regular[0];
|
||||
const tempPaths = Regular.map((_, index) =>
|
||||
path.resolve(outputDir, `temp_${family}_${index}.ttf`),
|
||||
@ -165,10 +173,18 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
||||
|
||||
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) {
|
||||
fallbackFontsPaths.push(emojiPath_2048);
|
||||
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
|
||||
} else {
|
||||
fallbackFontsPaths.push(emojiPath);
|
||||
fallbackFontsPaths.push(emojiPath, liberationPath);
|
||||
}
|
||||
|
||||
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts
|
||||
@ -196,10 +212,12 @@ module.exports.woff2ServerPlugin = (options = {}) => {
|
||||
const base = baseFont.data.name[field];
|
||||
const xiaolai = xiaolaiFont.data.name[field];
|
||||
const emoji = emojiFont.data.name[field];
|
||||
const liberation = liberationFont.data.name[field];
|
||||
// liberation font
|
||||
|
||||
return shouldIncludeXiaolaiFallback
|
||||
? `${base} & ${xiaolai} & ${emoji}`
|
||||
: `${base} & ${emoji}`;
|
||||
? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
|
||||
: `${base} & ${emoji} & ${liberation}`;
|
||||
};
|
||||
|
||||
mergedFont.set({
|
||||
|
Reference in New Issue
Block a user