Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5d62c8d6 | ||
|
|
4f74274d04 |
@@ -121,7 +121,6 @@ import {
|
|||||||
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;
|
||||||
|
|
||||||
@@ -631,12 +630,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>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AnimatedTrail implements Trail {
|
export class AnimatedTrail implements Trail {
|
||||||
private currentTrail?: LaserPointer;
|
currentTrail?: LaserPointer;
|
||||||
private pastTrails: LaserPointer[] = [];
|
private pastTrails: LaserPointer[] = [];
|
||||||
|
|
||||||
private container?: SVGSVGElement;
|
private container?: SVGSVGElement;
|
||||||
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private animationFrameHandler: AnimationFrameHandler,
|
private animationFrameHandler: AnimationFrameHandler,
|
||||||
private app: App,
|
protected app: App,
|
||||||
private options: Partial<LaserPointerOptions> &
|
private options: Partial<LaserPointerOptions> &
|
||||||
Partial<AnimatedTrailOptions>,
|
Partial<AnimatedTrailOptions>,
|
||||||
) {
|
) {
|
||||||
@@ -98,6 +98,16 @@ export class AnimatedTrail implements Trail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCurrentTrail() {
|
||||||
|
return this.currentTrail;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTrails() {
|
||||||
|
this.pastTrails = [];
|
||||||
|
this.currentTrail = undefined;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
isCropping: false,
|
isCropping: false,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
searchMatches: [],
|
searchMatches: [],
|
||||||
|
lassoSelectionEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
isCropping: { browser: false, export: false, server: false },
|
isCropping: { browser: false, export: false, server: false },
|
||||||
croppingElementId: { browser: false, export: false, server: false },
|
croppingElementId: { browser: false, export: false, server: false },
|
||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
|
lassoSelectionEnabled: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ import { cropElement } from "../element/cropElement";
|
|||||||
import { wrapText } from "../element/textWrapping";
|
import { wrapText } from "../element/textWrapping";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||||
|
import { LassoTrail } from "../lasso";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -635,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: "rgba(255, 255, 255, 0.2)",
|
: "rgba(255, 255, 255, 0.2)",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||||
|
|
||||||
onChangeEmitter = new Emitter<
|
onChangeEmitter = new Emitter<
|
||||||
[
|
[
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -1607,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
<div className="excalidraw-contextMenuContainer" />
|
<div className="excalidraw-contextMenuContainer" />
|
||||||
<div className="excalidraw-eye-dropper-container" />
|
<div className="excalidraw-eye-dropper-container" />
|
||||||
<SVGLayer
|
<SVGLayer
|
||||||
trails={[this.laserTrails, this.eraserTrail]}
|
trails={[
|
||||||
|
this.laserTrails,
|
||||||
|
this.eraserTrail,
|
||||||
|
this.lassoTrail,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
this.state.openDialog?.name !==
|
this.state.openDialog?.name !==
|
||||||
@@ -4515,6 +4522,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS[1] && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
if (this.state.activeTool.type === "selection") {
|
||||||
|
this.setActiveTool({ type: "lassoSelection" });
|
||||||
|
} else {
|
||||||
|
this.setActiveTool({ type: "selection" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||||
@@ -6545,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.activeTool.type,
|
this.state.activeTool.type,
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
);
|
);
|
||||||
|
} else if (this.state.activeTool.type === "lassoSelection") {
|
||||||
|
// Begin a mark capture. This does not have to update state yet.
|
||||||
|
const [gridX, gridY] = getGridPoint(
|
||||||
|
pointerDownState.origin.x,
|
||||||
|
pointerDownState.origin.y,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.lassoTrail.startPath(gridX, gridY);
|
||||||
} else if (this.state.activeTool.type === "custom") {
|
} else if (this.state.activeTool.type === "custom") {
|
||||||
setCursorForShape(this.interactiveCanvas, this.state);
|
setCursorForShape(this.interactiveCanvas, this.state);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -8461,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||||
this.maybeDragNewGenericElement(pointerDownState, event);
|
this.maybeDragNewGenericElement(pointerDownState, event);
|
||||||
|
} else if (this.state.activeTool.type === "lassoSelection") {
|
||||||
|
const { intersectedElementIds, enclosedElementIds } =
|
||||||
|
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||||
|
|
||||||
|
this.setState((prevState) => {
|
||||||
|
const elements = [...intersectedElementIds, ...enclosedElementIds];
|
||||||
|
|
||||||
|
const nextSelectedElementIds = elements.reduce((acc, id) => {
|
||||||
|
acc[id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<ExcalidrawElement["id"], true>);
|
||||||
|
|
||||||
|
const nextSelectedGroupIds = selectGroupsForSelectedElements(
|
||||||
|
{
|
||||||
|
selectedElementIds: nextSelectedElementIds,
|
||||||
|
editingGroupId: prevState.editingGroupId,
|
||||||
|
},
|
||||||
|
this.scene.getNonDeletedElements(),
|
||||||
|
prevState,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: not entirely correct (need to select all elements in group instead)
|
||||||
|
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
|
||||||
|
if (selected) {
|
||||||
|
const element = this.scene.getNonDeletedElement(id);
|
||||||
|
if (element && element.groupIds.length > 0) {
|
||||||
|
delete nextSelectedElementIds[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make elegant and decide if all children are selected, do we keep?
|
||||||
|
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
|
||||||
|
if (selected) {
|
||||||
|
const element = this.scene.getNonDeletedElement(id);
|
||||||
|
|
||||||
|
if (element && isFrameLikeElement(element)) {
|
||||||
|
const elementsInFrame = getFrameChildren(
|
||||||
|
elementsMap,
|
||||||
|
element.id,
|
||||||
|
);
|
||||||
|
for (const child of elementsInFrame) {
|
||||||
|
delete nextSelectedElementIds[child.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
nextSelectedElementIds,
|
||||||
|
prevState,
|
||||||
|
),
|
||||||
|
selectedGroupIds: nextSelectedGroupIds.selectedGroupIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// It is very important to read this.state within each move event,
|
// It is very important to read this.state within each move event,
|
||||||
// otherwise we would read a stale one!
|
// otherwise we would read a stale one!
|
||||||
@@ -8715,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
originSnapOffset: null,
|
originSnapOffset: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
this.lassoTrail.endPath();
|
||||||
|
|
||||||
this.lastPointerMoveCoords = null;
|
this.lastPointerMoveCoords = null;
|
||||||
|
|
||||||
SnapCache.setReferenceSnapPoints(null);
|
SnapCache.setReferenceSnapPoints(null);
|
||||||
@@ -8881,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImageElement(newElement)) {
|
if (isImageElement(newElement)) {
|
||||||
const imageElement = newElement;
|
const imageElement = newElement;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.excalifont {
|
.excalifont {
|
||||||
font-family: "Excalifont", "Xiaolai";
|
font-family: "Excalifont";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WelcomeSreen common
|
// WelcomeSreen common
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
|||||||
// use these constants to easily identify reference sites
|
// use these constants to easily identify reference sites
|
||||||
export const TOOL_TYPE = {
|
export const TOOL_TYPE = {
|
||||||
selection: "selection",
|
selection: "selection",
|
||||||
|
lassoSelection: "lassoSelection",
|
||||||
rectangle: "rectangle",
|
rectangle: "rectangle",
|
||||||
diamond: "diamond",
|
diamond: "diamond",
|
||||||
ellipse: "ellipse",
|
ellipse: "ellipse",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
boolean
|
boolean
|
||||||
> = {
|
> = {
|
||||||
selection: true,
|
selection: true,
|
||||||
|
lassoSelection: true,
|
||||||
text: true,
|
text: true,
|
||||||
rectangle: true,
|
rectangle: true,
|
||||||
diamond: true,
|
diamond: true,
|
||||||
|
|||||||
@@ -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("<test>");
|
||||||
expect(normalizeLink("test&")).toBe("test&");
|
expect(normalizeLink("test&")).toBe("test&");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -324,6 +325,15 @@ export const getElementLineSegments = (
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFrameLikeElement(element)) {
|
||||||
|
return [
|
||||||
|
lineSegment(nw, ne),
|
||||||
|
lineSegment(ne, se),
|
||||||
|
lineSegment(se, sw),
|
||||||
|
lineSegment(sw, nw),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
lineSegment(nw, ne),
|
lineSegment(nw, ne),
|
||||||
lineSegment(sw, se),
|
lineSegment(sw, se),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* all things related to lasso selection
|
||||||
|
* - lasso selection
|
||||||
|
* - intersection and enclosure checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
type LineSegment,
|
||||||
|
type LocalPoint,
|
||||||
|
type Polygon,
|
||||||
|
pointFrom,
|
||||||
|
pointsEqual,
|
||||||
|
polygonFromPoints,
|
||||||
|
segmentsIntersectAt,
|
||||||
|
} from "../math";
|
||||||
|
import { isPointInShape } from "../utils/collision";
|
||||||
|
import {
|
||||||
|
type GeometricShape,
|
||||||
|
polylineFromPoints,
|
||||||
|
} from "../utils/geometry/shape";
|
||||||
|
import { AnimatedTrail } from "./animated-trail";
|
||||||
|
import { type AnimationFrameHandler } from "./animation-frame-handler";
|
||||||
|
import type App from "./components/App";
|
||||||
|
import { getElementLineSegments } from "./element/bounds";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
|
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||||
|
import type { InteractiveCanvasAppState } from "./types";
|
||||||
|
import { easeOut } from "./utils";
|
||||||
|
|
||||||
|
export type LassoPath = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
points: LocalPoint[];
|
||||||
|
intersectedElements: Set<ExcalidrawElement["id"]>;
|
||||||
|
enclosedElements: Set<ExcalidrawElement["id"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderLassoSelection = (
|
||||||
|
lassoPath: LassoPath,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: InteractiveCanvasAppState,
|
||||||
|
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.translate(
|
||||||
|
lassoPath.x + appState.scrollX,
|
||||||
|
lassoPath.y + appState.scrollY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPoint = lassoPath.points[0];
|
||||||
|
|
||||||
|
if (firstPoint) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(firstPoint[0], firstPoint[1]);
|
||||||
|
|
||||||
|
for (let i = 1; i < lassoPath.points.length; i++) {
|
||||||
|
context.lineTo(lassoPath.points[i][0], lassoPath.points[i][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.strokeStyle = selectionColor;
|
||||||
|
context.lineWidth = 3 / appState.zoom.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lassoPath.points.length >= 3 &&
|
||||||
|
pointsEqual(
|
||||||
|
lassoPath.points[0],
|
||||||
|
lassoPath.points[lassoPath.points.length - 1],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
// export class LassoSelection {
|
||||||
|
// static createLassoPath = (x: number, y: number): LassoPath => {
|
||||||
|
// return {
|
||||||
|
// x,
|
||||||
|
// y,
|
||||||
|
// points: [],
|
||||||
|
// intersectedElements: new Set(),
|
||||||
|
// enclosedElements: new Set(),
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// static updateLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// pointerCoords: { x: number; y: number },
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ): LassoPath => {
|
||||||
|
// const points = lassoPath.points;
|
||||||
|
// const dx = pointerCoords.x - lassoPath.x;
|
||||||
|
// const dy = pointerCoords.y - lassoPath.y;
|
||||||
|
|
||||||
|
// const lastPoint = points.length > 0 && points[points.length - 1];
|
||||||
|
// const discardPoint =
|
||||||
|
// lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
|
||||||
|
|
||||||
|
// if (!discardPoint) {
|
||||||
|
// const nextLassoPath = {
|
||||||
|
// ...lassoPath,
|
||||||
|
// points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // nextLassoPath.enclosedElements.clear();
|
||||||
|
|
||||||
|
// // const enclosedLassoPath = LassoSelection.closeLassoPath(
|
||||||
|
// // nextLassoPath,
|
||||||
|
// // elementsMap,
|
||||||
|
// // );
|
||||||
|
|
||||||
|
// // for (const [id, element] of elementsMap) {
|
||||||
|
// // if (!lassoPath.intersectedElements.has(element.id)) {
|
||||||
|
// // const intersects = intersect(nextLassoPath, element, elementsMap);
|
||||||
|
// // if (intersects) {
|
||||||
|
// // lassoPath.intersectedElements.add(element.id);
|
||||||
|
// // } else {
|
||||||
|
// // // check if the lasso path encloses the element
|
||||||
|
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (enclosed) {
|
||||||
|
// // lassoPath.enclosedElements.add(element.id);
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// return nextLassoPath;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return lassoPath;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// private static closeLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ) => {
|
||||||
|
// const finalPoints = [...lassoPath.points, lassoPath.points[0]];
|
||||||
|
// // TODO: check if the lasso path encloses or intersects with any element
|
||||||
|
|
||||||
|
// const finalLassoPath = {
|
||||||
|
// ...lassoPath,
|
||||||
|
// points: finalPoints,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return finalLassoPath;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// static finalizeLassoPath = (
|
||||||
|
// lassoPath: LassoPath,
|
||||||
|
// elementsMap: ElementsMap,
|
||||||
|
// ) => {
|
||||||
|
// const enclosedLassoPath = LassoSelection.closeLassoPath(
|
||||||
|
// lassoPath,
|
||||||
|
// elementsMap,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// enclosedLassoPath.enclosedElements.clear();
|
||||||
|
// enclosedLassoPath.intersectedElements.clear();
|
||||||
|
|
||||||
|
// // for (const [id, element] of elementsMap) {
|
||||||
|
// // const intersects = intersect(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (intersects) {
|
||||||
|
// // enclosedLassoPath.intersectedElements.add(element.id);
|
||||||
|
// // } else {
|
||||||
|
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
|
||||||
|
// // if (enclosed) {
|
||||||
|
// // enclosedLassoPath.enclosedElements.add(element.id);
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// return enclosedLassoPath;
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
const intersectionTest = (
|
||||||
|
lassoPath: GlobalPoint[],
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
const elementLineSegments = getElementLineSegments(element, elementsMap);
|
||||||
|
const lassoSegments = lassoPath.reduce((acc, point, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const prevPoint = pointFrom<GlobalPoint>(
|
||||||
|
lassoPath[index - 1][0],
|
||||||
|
lassoPath[index - 1][1],
|
||||||
|
);
|
||||||
|
const currentPoint = pointFrom<GlobalPoint>(point[0], point[1]);
|
||||||
|
acc.push([prevPoint, currentPoint] as LineSegment<GlobalPoint>);
|
||||||
|
return acc;
|
||||||
|
}, [] as LineSegment<GlobalPoint>[]);
|
||||||
|
|
||||||
|
for (const lassoSegment of lassoSegments) {
|
||||||
|
for (const elementSegment of elementLineSegments) {
|
||||||
|
if (segmentsIntersectAt(lassoSegment, elementSegment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enclosureTest = (
|
||||||
|
lassoPath: GlobalPoint[],
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): boolean => {
|
||||||
|
const polyline = polylineFromPoints(lassoPath);
|
||||||
|
|
||||||
|
const closedPathShape: GeometricShape<GlobalPoint> = {
|
||||||
|
type: "polygon",
|
||||||
|
data: polygonFromPoints(polyline.flat()),
|
||||||
|
} as {
|
||||||
|
type: "polygon";
|
||||||
|
data: Polygon<GlobalPoint>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementSegments = getElementLineSegments(element, elementsMap);
|
||||||
|
|
||||||
|
for (const segment of elementSegments) {
|
||||||
|
if (segment.some((point) => isPointInShape(point, closedPathShape))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LassoTrail extends AnimatedTrail {
|
||||||
|
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
|
||||||
|
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||||
|
super(animationFrameHandler, app, {
|
||||||
|
simplify: 0,
|
||||||
|
streamline: 0.4,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const DECAY_TIME = Infinity;
|
||||||
|
const DECAY_LENGTH = 5000;
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
1 - (performance.now() - c.pressure) / DECAY_TIME,
|
||||||
|
);
|
||||||
|
const l =
|
||||||
|
(DECAY_LENGTH -
|
||||||
|
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
||||||
|
DECAY_LENGTH;
|
||||||
|
|
||||||
|
return Math.min(easeOut(l), easeOut(t));
|
||||||
|
},
|
||||||
|
fill: () => "rgb(0,118,255)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number) {
|
||||||
|
super.startPath(x, y);
|
||||||
|
this.intersectedElements.clear();
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number) {
|
||||||
|
super.addPointToPath(x, y);
|
||||||
|
const lassoPath = super
|
||||||
|
.getCurrentTrail()
|
||||||
|
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
|
||||||
|
if (lassoPath) {
|
||||||
|
// TODO: further OPT: do not check elements that are "far away"
|
||||||
|
const elementsMap = this.app.scene.getNonDeletedElementsMap();
|
||||||
|
const closedPath = polygonFromPoints(lassoPath);
|
||||||
|
// need to clear the enclosed elements as path might change
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
for (const [, element] of elementsMap) {
|
||||||
|
if (!this.intersectedElements.has(element.id)) {
|
||||||
|
const intersects = intersectionTest(lassoPath, element, elementsMap);
|
||||||
|
if (intersects) {
|
||||||
|
this.intersectedElements.add(element.id);
|
||||||
|
} else {
|
||||||
|
// TODO: check bounding box is at least in the lasso path area first
|
||||||
|
// BUT: need to compare bounding box check with enclosure check performance
|
||||||
|
const enclosed = enclosureTest(closedPath, element, elementsMap);
|
||||||
|
if (enclosed) {
|
||||||
|
this.enclosedElements.add(element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
intersectedElementIds: this.intersectedElements,
|
||||||
|
enclosedElementIds: this.enclosedElements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath(): void {
|
||||||
|
super.endPath();
|
||||||
|
super.clearTrails();
|
||||||
|
this.intersectedElements.clear();
|
||||||
|
this.enclosedElements.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import { getCornerRadius } from "../shapes";
|
import { getCornerRadius } from "../shapes";
|
||||||
|
import { renderLassoSelection } from "../lasso";
|
||||||
|
|
||||||
const renderElbowArrowMidPointHighlight = (
|
const renderElbowArrowMidPointHighlight = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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("&"'><");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
|||||||
|
|
||||||
export type ToolType =
|
export type ToolType =
|
||||||
| "selection"
|
| "selection"
|
||||||
|
| "lassoSelection"
|
||||||
| "rectangle"
|
| "rectangle"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
| "ellipse"
|
| "ellipse"
|
||||||
@@ -408,6 +409,8 @@ export interface AppState {
|
|||||||
croppingElementId: ExcalidrawElement["id"] | null;
|
croppingElementId: ExcalidrawElement["id"] | null;
|
||||||
|
|
||||||
searchMatches: readonly SearchMatch[];
|
searchMatches: readonly SearchMatch[];
|
||||||
|
|
||||||
|
lassoSelectionEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchMatch = {
|
type SearchMatch = {
|
||||||
@@ -681,8 +684,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<{
|
||||||
|
|||||||
@@ -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, """);
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
|
export const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
|
||||||
points: Point[],
|
points: Point[],
|
||||||
): Polyline<Point> => {
|
): Polyline<Point> => {
|
||||||
let previousPoint: Point = points[0];
|
let previousPoint: Point = points[0];
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user