Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bcb92a296 | |||
| d9a45dce9c | |||
| 8a6e644fc0 | |||
| 4bd788ffcc | |||
| 391497916f | |||
| 03a03e8d21 | |||
| 8ee20225cf | |||
| 25c3256908 | |||
| d5f4ee7b3f | |||
| 261304c1a4 | |||
| 84398a7e5c | |||
| 54491d13d4 |
@@ -128,29 +128,6 @@
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</script>
|
||||
|
||||
<!-- in DEV we need to preload from the local server and without the hash -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<% } %>
|
||||
|
||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||
|
||||
@@ -73,8 +73,8 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
// Don't push fonts, locales and wasm to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
@@ -108,6 +108,17 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp(".wasm-.+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "wasm",
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import type { History } from "../history";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import type { AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
@@ -13,7 +13,8 @@ import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const writeData = (
|
||||
const executeHistoryAction = (
|
||||
app: AppClassProperties,
|
||||
appState: Readonly<AppState>,
|
||||
updater: () => [SceneElementsMap, AppState] | void,
|
||||
): ActionResult => {
|
||||
@@ -23,7 +24,8 @@ const writeData = (
|
||||
!appState.editingElement &&
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
!appState.selectionElement
|
||||
!appState.selectionElement &&
|
||||
!app.flowChartCreator.isCreatingChart
|
||||
) {
|
||||
const result = updater();
|
||||
|
||||
@@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
writeData(appState, () =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
@@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) =>
|
||||
writeData(appState, () =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
|
||||
@@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawElement | null
|
||||
>();
|
||||
let uniqueGlyphs = new Set<string>();
|
||||
let uniqueChars = new Set<string>();
|
||||
let skipFontFaceCheck = false;
|
||||
|
||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||
@@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
uniqueGlyphs = new Set([
|
||||
...uniqueGlyphs,
|
||||
uniqueChars = new Set([
|
||||
...uniqueChars,
|
||||
...Array.from(newElement.originalText),
|
||||
]);
|
||||
}
|
||||
@@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
|
||||
const fontString = `10px ${getFontFamilyString({
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||
const chars = Array.from(uniqueChars.values()).join();
|
||||
|
||||
if (
|
||||
skipFontFaceCheck ||
|
||||
window.document.fonts.check(fontString, glyphs)
|
||||
) {
|
||||
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
@@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
|
||||
@@ -162,6 +162,7 @@ import {
|
||||
isMagicFrameElement,
|
||||
isTextBindableContainer,
|
||||
isElbowArrow,
|
||||
isFlowchartNodeElement,
|
||||
} from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@@ -206,7 +207,10 @@ import {
|
||||
isArrowKey,
|
||||
KEYS,
|
||||
} from "../keys";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
import {
|
||||
isElementCompletelyInViewport,
|
||||
isElementInViewport,
|
||||
} from "../element/sizeHelpers";
|
||||
import {
|
||||
distance2d,
|
||||
getCornerRadius,
|
||||
@@ -430,6 +434,11 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
getLinkDirectionFromKey,
|
||||
} from "../element/flowchart";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -564,6 +573,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||
|
||||
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||
|
||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||
@@ -1154,6 +1166,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
el,
|
||||
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
|
||||
this.elementsPendingErasure,
|
||||
null,
|
||||
),
|
||||
["--embeddable-radius" as string]: `${getCornerRadius(
|
||||
Math.min(el.width, el.height),
|
||||
@@ -1675,6 +1688,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.viewBackgroundColor,
|
||||
embedsValidationStatus: this.embedsValidationStatus,
|
||||
elementsPendingErasure: this.elementsPendingErasure,
|
||||
pendingFlowchartNodes:
|
||||
this.flowChartCreator.pendingNodes,
|
||||
}}
|
||||
/>
|
||||
<InteractiveCanvas
|
||||
@@ -3872,6 +3887,90 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ESCAPE && this.flowChartCreator.isCreatingChart) {
|
||||
this.flowChartCreator.clear();
|
||||
this.triggerRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const arrowKeyPressed = isArrowKey(event.key);
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && arrowKeyPressed && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
isFlowchartNodeElement(selectedElements[0])
|
||||
) {
|
||||
this.flowChartCreator.createNodes(
|
||||
selectedElements[0],
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
getLinkDirectionFromKey(event.key),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (selectedElements.length === 1 && arrowKeyPressed) {
|
||||
event.preventDefault();
|
||||
|
||||
const nextId = this.flowChartNavigator.exploreByDirection(
|
||||
selectedElements[0],
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
getLinkDirectionFromKey(event.key),
|
||||
);
|
||||
|
||||
if (nextId) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
[nextId]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
}));
|
||||
|
||||
const nextNode = this.scene.getNonDeletedElementsMap().get(nextId);
|
||||
|
||||
if (
|
||||
nextNode &&
|
||||
!isElementCompletelyInViewport(
|
||||
nextNode,
|
||||
this.canvas.width / window.devicePixelRatio,
|
||||
this.canvas.height / window.devicePixelRatio,
|
||||
{
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
offsetTop: this.state.offsetTop,
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
zoom: this.state.zoom,
|
||||
},
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
this.scrollToContent(nextNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.P &&
|
||||
@@ -4238,6 +4337,58 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
this.setState({ suggestedBindings: [] });
|
||||
}
|
||||
|
||||
if (!event.altKey) {
|
||||
if (this.flowChartNavigator.isExploring) {
|
||||
this.flowChartNavigator.clear();
|
||||
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.flowChartCreator.isCreatingChart) {
|
||||
if (this.flowChartCreator.pendingNodes?.length) {
|
||||
this.scene.insertElements(this.flowChartCreator.pendingNodes);
|
||||
}
|
||||
|
||||
const firstNode = this.flowChartCreator.pendingNodes?.[0];
|
||||
|
||||
if (firstNode) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
[firstNode.id]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
}));
|
||||
|
||||
if (
|
||||
!isElementCompletelyInViewport(
|
||||
firstNode,
|
||||
this.canvas.width / window.devicePixelRatio,
|
||||
this.canvas.height / window.devicePixelRatio,
|
||||
{
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
offsetTop: this.state.offsetTop,
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
zoom: this.state.zoom,
|
||||
},
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
this.scrollToContent(firstNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.flowChartCreator.clear();
|
||||
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We purposely widen the `tool` type so this helper can be called with
|
||||
@@ -7122,7 +7273,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
|
||||
this.setState((prevState) => {
|
||||
const nextSelectedElementIds = {
|
||||
...prevState.selectedElementIds,
|
||||
|
||||
@@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
className="HelpDialog__island--editor"
|
||||
caption={t("helpDialog.editor")}
|
||||
>
|
||||
<Shortcut
|
||||
label={t("helpDialog.createFlowchart")}
|
||||
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.navigateFlowchart")}
|
||||
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.moveCanvas")}
|
||||
shortcuts={[
|
||||
|
||||
@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "../i18n";
|
||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||
import {
|
||||
isFlowchartNodeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextBindableContainer,
|
||||
@@ -10,6 +11,7 @@ import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { isNodeInFlowchart } from "../element/flowchart";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
@@ -18,7 +20,12 @@ interface HintViewerProps {
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
const getHints = ({
|
||||
appState,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
}: HintViewerProps): null | string | string[] => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
@@ -115,6 +122,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
isTextBindableContainer(selectedElements[0])
|
||||
) {
|
||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||
if (
|
||||
isNodeInFlowchart(
|
||||
selectedElements[0],
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
}
|
||||
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
}
|
||||
|
||||
return t("hints.bindTextToElement");
|
||||
}
|
||||
}
|
||||
@@ -129,17 +149,24 @@ export const HintViewer = ({
|
||||
device,
|
||||
app,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
const hints = getHints({
|
||||
appState,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
});
|
||||
if (!hint) {
|
||||
|
||||
if (!hints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hint = getShortcutKey(hint);
|
||||
const hint = Array.isArray(hints)
|
||||
? hints
|
||||
.map((hint) => {
|
||||
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||
})
|
||||
.join(". ")
|
||||
: getShortcutKey(hints);
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
|
||||
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
||||
@@ -51,6 +51,7 @@ import { normalizeLink } from "./url";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { normalizeFixedPoint } from "../element/binding";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -106,7 +107,7 @@ const repairBinding = (
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
fixedPoint: isElbowArrow(element)
|
||||
? binding.fixedPoint ?? ([0, 0] as [number, number])
|
||||
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -759,6 +759,11 @@ export const bindPointToSnapToElementOutline = (
|
||||
[point[0], point[1] + 2 * bindableElement.height],
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
).map((i) =>
|
||||
distanceToBindableElement(bindableElement, i, elementsMap) >=
|
||||
bindableElement.height / 2
|
||||
? ([point[0], -1 * i[1]] as Point)
|
||||
: ([point[0], i[1]] as Point),
|
||||
),
|
||||
...intersectElementWithLine(
|
||||
bindableElement,
|
||||
@@ -766,13 +771,13 @@ export const bindPointToSnapToElementOutline = (
|
||||
[point[0] + 2 * bindableElement.width, point[1]],
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
).map((i) =>
|
||||
distanceToBindableElement(bindableElement, i, elementsMap) >=
|
||||
bindableElement.width / 2
|
||||
? ([-1 * i[0], point[1]] as Point)
|
||||
: ([i[0], point[1]] as Point),
|
||||
),
|
||||
].map((i) =>
|
||||
distanceToBindableElement(bindableElement, i, elementsMap) >
|
||||
Math.min(bindableElement.width, bindableElement.height) / 2
|
||||
? ([-1 * i[0], -1 * i[1]] as Point)
|
||||
: i,
|
||||
);
|
||||
];
|
||||
|
||||
const heading = headingForPointFromElement(bindableElement, aabb, point);
|
||||
const isVertical =
|
||||
@@ -1000,7 +1005,7 @@ const updateBoundPoint = (
|
||||
|
||||
if (isElbowArrow(linearElement)) {
|
||||
const fixedPoint =
|
||||
binding.fixedPoint ??
|
||||
normalizeFixedPoint(binding.fixedPoint) ??
|
||||
calculateFixedPointForElbowArrowBinding(
|
||||
linearElement,
|
||||
bindableElement,
|
||||
@@ -1113,12 +1118,12 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
) as Point;
|
||||
|
||||
return {
|
||||
fixedPoint: [
|
||||
fixedPoint: normalizeFixedPoint([
|
||||
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
|
||||
hoveredElement.width,
|
||||
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
|
||||
hoveredElement.height,
|
||||
] as [number, number],
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2171,7 +2176,8 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
fixedPointRatio: [number, number],
|
||||
element: ExcalidrawBindableElement,
|
||||
) => {
|
||||
const [fixedX, fixedY] = fixedPointRatio;
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
return rotatePoint(
|
||||
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
||||
getCenterForElement(element),
|
||||
@@ -2225,3 +2231,16 @@ export const getArrowLocalFixedPoints = (
|
||||
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
|
||||
];
|
||||
};
|
||||
|
||||
export const normalizeFixedPoint = <T extends FixedPoint | null>(
|
||||
fixedPoint: T,
|
||||
): T extends null ? null : FixedPoint => {
|
||||
// Do not allow a precise 0.5 for fixed point ratio
|
||||
// to avoid jumping arrow heading due to floating point imprecision
|
||||
if (fixedPoint && (fixedPoint[0] === 0.5 || fixedPoint[1] === 0.5)) {
|
||||
return fixedPoint.map((ratio) =>
|
||||
ratio === 0.5 ? 0.5001 : ratio,
|
||||
) as T extends null ? null : FixedPoint;
|
||||
}
|
||||
return fixedPoint as any as T extends null ? null : FixedPoint;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { reseed } from "../random";
|
||||
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
mouse.reset();
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
|
||||
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||
// removing this style for testing
|
||||
UI.clickTool("arrow");
|
||||
UI.clickByTitle("Architect");
|
||||
UI.clickTool("selection");
|
||||
});
|
||||
|
||||
describe("flow chart creation", () => {
|
||||
beforeEach(() => {
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
API.setSelectedElements([rectangle]);
|
||||
});
|
||||
|
||||
// multiple at once
|
||||
it("create multiple successor nodes at once", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
});
|
||||
|
||||
it("when directions are changed, only the last same directions will apply", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
});
|
||||
|
||||
it("when escaped, no nodes will be created", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create nodes one at a time", () => {
|
||||
const initialNode = h.elements[0];
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||
|
||||
const firstChildNode = h.elements.filter(
|
||||
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||
)[0];
|
||||
expect(firstChildNode).not.toBe(null);
|
||||
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
|
||||
const secondChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id,
|
||||
)[0];
|
||||
expect(secondChildNode).not.toBe(null);
|
||||
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
|
||||
const thirdChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id &&
|
||||
el.id !== secondChildNode.id,
|
||||
)[0];
|
||||
|
||||
expect(thirdChildNode).not.toBe(null);
|
||||
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flow chart navigation", () => {
|
||||
it("single node at each level", () => {
|
||||
/**
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||
|
||||
// all the way to the left, gets us to the first node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// all the way to the right, gets us to the last node
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode);
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("multiple nodes at each level", () => {
|
||||
/**
|
||||
* from the perspective of the first node, there're four layers, and
|
||||
* there are four nodes at the second layer
|
||||
*
|
||||
* -> ▨
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
* -> ▨
|
||||
* -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
const secondNode = h.elements[1];
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
// because of same level cycling,
|
||||
// going right five times should take us back to the second node again
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||
|
||||
// from the second node, going right three times should take us to the rightmost node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("take the most obvious link when possible", () => {
|
||||
/**
|
||||
* ▨ → ▨ ▨ → ▨
|
||||
* ↓ ↑
|
||||
* ▨ → ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [rectangle];
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
// last node should be the one that's selected
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// going any direction takes us to the predecessor as well
|
||||
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,698 @@
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
headingForPointFromElement,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "../math";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppState, PendingExcalidrawElements, Point } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
isElbowArrow,
|
||||
isFrameElement,
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
const VERTICAL_OFFSET = 100;
|
||||
const HORIZONTAL_OFFSET = 100;
|
||||
|
||||
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
|
||||
switch (key) {
|
||||
case KEYS.ARROW_UP:
|
||||
return "up";
|
||||
case KEYS.ARROW_DOWN:
|
||||
return "down";
|
||||
case KEYS.ARROW_RIGHT:
|
||||
return "right";
|
||||
case KEYS.ARROW_LEFT:
|
||||
return "left";
|
||||
default:
|
||||
return "right";
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeRelatives = (
|
||||
type: "predecessors" | "successors",
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const items = [...elementsMap.values()].reduce(
|
||||
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
|
||||
let oppositeBinding;
|
||||
if (
|
||||
isElbowArrow(el) &&
|
||||
// we want check existence of the opposite binding, in the direction
|
||||
// we're interested in
|
||||
(oppositeBinding =
|
||||
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||
// similarly, we need to filter only arrows bound to target node
|
||||
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||
?.elementId === node.id
|
||||
) {
|
||||
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||
|
||||
if (!relative) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
invariant(
|
||||
isBindableElement(relative),
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint: Point =
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
|
||||
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
]);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
heading,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||
.map((item) => item.relative);
|
||||
case "down":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||
.map((item) => item.relative);
|
||||
case "right":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||
.map((item) => item.relative);
|
||||
case "left":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||
.map((item) => item.relative);
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("successors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
export const getPredecessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("predecessors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
const getOffsets = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
linkedNodes: ExcalidrawElement[],
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
|
||||
|
||||
// check if vertical space or horizontal space is available first
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
// check vertical space
|
||||
const minX = element.x;
|
||||
const maxX = element.x + element.width;
|
||||
|
||||
// vertical space is available
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x: 0,
|
||||
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
|
||||
};
|
||||
}
|
||||
} else if (direction === "right" || direction === "left") {
|
||||
const minY = element.y;
|
||||
const maxY = element.y + element.height;
|
||||
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x:
|
||||
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
|
||||
const x =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
|
||||
|
||||
if (direction === "up") {
|
||||
return {
|
||||
x,
|
||||
y: y * -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const x =
|
||||
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
|
||||
element.width;
|
||||
const y =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
|
||||
|
||||
if (direction === "left") {
|
||||
return {
|
||||
x: x * -1,
|
||||
y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
};
|
||||
|
||||
const addNewNode = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const successors = getSuccessors(element, elementsMap, direction);
|
||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||
|
||||
const offsets = getOffsets(
|
||||
element,
|
||||
[...successors, ...predeccessors],
|
||||
direction,
|
||||
);
|
||||
|
||||
const nextNode = newElement({
|
||||
type: element.type,
|
||||
x: element.x + offsets.x,
|
||||
y: element.y + offsets.y,
|
||||
// TODO: extract this to a util
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
roundness: element.roundness,
|
||||
roughness: element.roughness,
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
element,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
nextNode,
|
||||
bindingArrow,
|
||||
};
|
||||
};
|
||||
|
||||
export const addNewNodes = (
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
numberOfNodes: number,
|
||||
) => {
|
||||
// always start from 0 and distribute evenly
|
||||
const newNodes: ExcalidrawElement[] = [];
|
||||
|
||||
for (let i = 0; i < numberOfNodes; i++) {
|
||||
let nextX: number;
|
||||
let nextY: number;
|
||||
if (direction === "left" || direction === "right") {
|
||||
const totalHeight =
|
||||
VERTICAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.height;
|
||||
|
||||
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
|
||||
|
||||
let offsetX = HORIZONTAL_OFFSET + startNode.width;
|
||||
if (direction === "left") {
|
||||
offsetX *= -1;
|
||||
}
|
||||
nextX = startNode.x + offsetX;
|
||||
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
|
||||
nextY = startY + offsetY;
|
||||
} else {
|
||||
const totalWidth =
|
||||
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.width;
|
||||
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
|
||||
let offsetY = VERTICAL_OFFSET + startNode.height;
|
||||
|
||||
if (direction === "up") {
|
||||
offsetY *= -1;
|
||||
}
|
||||
nextY = startNode.y + offsetY;
|
||||
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
|
||||
nextX = startX + offsetX;
|
||||
}
|
||||
|
||||
const nextNode = newElement({
|
||||
type: startNode.type,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
// TODO: extract this to a util
|
||||
width: startNode.width,
|
||||
height: startNode.height,
|
||||
roundness: startNode.roundness,
|
||||
roughness: startNode.roughness,
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
startNode,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
);
|
||||
|
||||
newNodes.push(nextNode);
|
||||
newNodes.push(bindingArrow);
|
||||
}
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const createBindingArrow = (
|
||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
appState: AppState,
|
||||
) => {
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
|
||||
const PADDING = 6;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y - PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y + startBindingElement.height + PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
startX = startBindingElement.x + startBindingElement.width + PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
startX = startBindingElement.x - PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let endX: number;
|
||||
let endY: number;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y - startY - PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
endX = endBindingElement.x - startX - PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bindingArrow = newArrowElement({
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[endX, endY],
|
||||
],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"start",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"end",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
startBindingElement.id,
|
||||
startBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
endBindingElement.id,
|
||||
endBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
bindingArrow.id,
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
[
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
undefined,
|
||||
{
|
||||
changedElements,
|
||||
},
|
||||
);
|
||||
|
||||
return bindingArrow;
|
||||
};
|
||||
|
||||
export class FlowChartNavigator {
|
||||
isExploring: boolean = false;
|
||||
// nodes that are ONE link away (successor and predecessor both included)
|
||||
private sameLevelNodes: ExcalidrawElement[] = [];
|
||||
private sameLevelIndex: number = 0;
|
||||
// set it to the opposite of the defalut creation direction
|
||||
private direction: LinkDirection | null = null;
|
||||
// for speedier navigation
|
||||
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
||||
|
||||
clear() {
|
||||
this.isExploring = false;
|
||||
this.sameLevelNodes = [];
|
||||
this.sameLevelIndex = 0;
|
||||
this.direction = null;
|
||||
this.visitedNodes.clear();
|
||||
}
|
||||
|
||||
exploreByDirection(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
): ExcalidrawElement["id"] | null {
|
||||
if (!isBindableElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// clear if going at a different direction
|
||||
if (direction !== this.direction) {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// add the current node to the visited
|
||||
if (!this.visitedNodes.has(element.id)) {
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - already started exploring, AND
|
||||
* - there are multiple nodes at the same level, AND
|
||||
* - still going at the same direction, AND
|
||||
*
|
||||
* RESULT:
|
||||
* - loop through nodes at the same level
|
||||
*
|
||||
* WHY:
|
||||
* - provides user the capability to loop through nodes at the same level
|
||||
*/
|
||||
if (
|
||||
this.isExploring &&
|
||||
direction === this.direction &&
|
||||
this.sameLevelNodes.length > 1
|
||||
) {
|
||||
this.sameLevelIndex =
|
||||
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
||||
|
||||
return this.sameLevelNodes[this.sameLevelIndex].id;
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
...getSuccessors(element, elementsMap, direction),
|
||||
...getPredecessors(element, elementsMap, direction),
|
||||
];
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - just started exploring at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to the first node in the given direction
|
||||
*/
|
||||
if (nodes.length > 0) {
|
||||
this.sameLevelIndex = 0;
|
||||
this.isExploring = true;
|
||||
this.sameLevelNodes = nodes;
|
||||
this.direction = direction;
|
||||
this.visitedNodes.add(nodes[0].id);
|
||||
|
||||
return nodes[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - (just started exploring or still going at the same direction) OR
|
||||
* - there're no nodes at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to some other unvisited linked node
|
||||
*
|
||||
* WHY:
|
||||
* - provide a speedier navigation from a given node to some predecessor
|
||||
* without the user having to change arrow key
|
||||
*/
|
||||
if (direction === this.direction || !this.isExploring) {
|
||||
if (!this.isExploring) {
|
||||
// just started and no other nodes at the given direction
|
||||
// so the current node is technically the first visited node
|
||||
// (this is needed so that we don't get stuck between looping through )
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
const otherDirections: LinkDirection[] = [
|
||||
"up",
|
||||
"right",
|
||||
"down",
|
||||
"left",
|
||||
].filter((dir): dir is LinkDirection => dir !== direction);
|
||||
|
||||
const otherLinkedNodes = otherDirections
|
||||
.map((dir) => [
|
||||
...getSuccessors(element, elementsMap, dir),
|
||||
...getPredecessors(element, elementsMap, dir),
|
||||
])
|
||||
.flat()
|
||||
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||
|
||||
for (const linkedNode of otherLinkedNodes) {
|
||||
if (!this.visitedNodes.has(linkedNode.id)) {
|
||||
this.visitedNodes.add(linkedNode.id);
|
||||
this.isExploring = true;
|
||||
this.direction = direction;
|
||||
return linkedNode.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FlowChartCreator {
|
||||
isCreatingChart: boolean = false;
|
||||
private numberOfNodes: number = 0;
|
||||
private direction: LinkDirection | null = "right";
|
||||
pendingNodes: PendingExcalidrawElements | null = null;
|
||||
|
||||
createNodes(
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
) {
|
||||
if (direction !== this.direction) {
|
||||
const { nextNode, bindingArrow } = addNewNode(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
);
|
||||
|
||||
this.numberOfNodes = 1;
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = [nextNode, bindingArrow];
|
||||
} else {
|
||||
this.numberOfNodes += 1;
|
||||
const newNodes = addNewNodes(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
this.numberOfNodes,
|
||||
);
|
||||
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = newNodes;
|
||||
}
|
||||
|
||||
// add pending nodes to the same frame as the start node
|
||||
// if every pending node is at least intersecting with the frame
|
||||
if (startNode.frameId) {
|
||||
const frame = elementsMap.get(startNode.frameId);
|
||||
|
||||
invariant(
|
||||
frame && isFrameElement(frame),
|
||||
"not an ExcalidrawFrameElement",
|
||||
);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
this.pendingNodes.every(
|
||||
(node) =>
|
||||
elementsAreInFrameBounds([node], frame, elementsMap) ||
|
||||
elementOverlapsWithFrame(node, frame, elementsMap),
|
||||
)
|
||||
) {
|
||||
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||
mutateElement(
|
||||
node,
|
||||
{
|
||||
frameId: startNode.frameId,
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isCreatingChart = false;
|
||||
this.pendingNodes = null;
|
||||
this.direction = null;
|
||||
this.numberOfNodes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const isNodeInFlowchart = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
for (const [, el] of elementsMap) {
|
||||
if (
|
||||
el.type === "arrow" &&
|
||||
(el.startBinding?.elementId === element.id ||
|
||||
el.endBinding?.elementId === element.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { toBrandedType, tupleToCoors } from "../utils";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
@@ -741,7 +741,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (
|
||||
linearElementEditor.lastUncommittedPoint == null ||
|
||||
linearElementEditor.lastUncommittedPoint == null &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
mutateElement(element, {
|
||||
@@ -1447,9 +1447,15 @@ export class LinearElementEditor {
|
||||
: null;
|
||||
}
|
||||
|
||||
const mergedElementsMap = options?.changedElements
|
||||
? toBrandedType<SceneElementsMap>(
|
||||
new Map([...elementsMap, ...options.changedElements]),
|
||||
)
|
||||
: elementsMap;
|
||||
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
mergedElementsMap,
|
||||
nextPoints,
|
||||
[offsetX, offsetY],
|
||||
bindings,
|
||||
|
||||
@@ -55,6 +55,43 @@ export const isElementInViewport = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isElementCompletelyInViewport = (
|
||||
element: ExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
viewTransformations: {
|
||||
zoom: Zoom;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft,
|
||||
clientY: viewTransformations.offsetTop,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + width,
|
||||
clientY: viewTransformations.offsetTop + height,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
|
||||
return (
|
||||
x1 >= topLeftSceneCoords.x &&
|
||||
y1 >= topLeftSceneCoords.y &&
|
||||
x2 <= bottomRightSceneCoords.x &&
|
||||
y2 <= bottomRightSceneCoords.y
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@@ -219,6 +220,16 @@ export const isExcalidrawElement = (
|
||||
}
|
||||
};
|
||||
|
||||
export const isFlowchartNodeElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
|
||||
@@ -160,6 +160,11 @@ export type ExcalidrawGenericElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||
import loadWoff2 from "./wasm/woff2.loader";
|
||||
import loadHbSubset from "./wasm/hb-subset.loader";
|
||||
|
||||
export interface Font {
|
||||
urls: URL[];
|
||||
fontFace: FontFace;
|
||||
getContent(): Promise<string>;
|
||||
getContent(codePoints: ReadonlySet<number>): Promise<string>;
|
||||
}
|
||||
export const UNPKG_PROD_URL = `https://unpkg.com/${
|
||||
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
|
||||
import.meta.env.VITE_PKG_NAME
|
||||
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
|
||||
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
||||
@@ -32,21 +34,32 @@ export class ExcalidrawFont implements Font {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to fetch woff2 content, based on the registered urls.
|
||||
* Returns last defined url in case of errors.
|
||||
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
|
||||
*
|
||||
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
|
||||
* NOTE: assumes usage of `dataurl` outside the browser environment
|
||||
*
|
||||
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
|
||||
*/
|
||||
public async getContent(): Promise<string> {
|
||||
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
|
||||
let i = 0;
|
||||
const errorMessages = [];
|
||||
|
||||
while (i < this.urls.length) {
|
||||
const url = this.urls[i];
|
||||
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return url.toString();
|
||||
const arrayBuffer = Buffer.from(
|
||||
url.toString().split(",")[1],
|
||||
"base64",
|
||||
).buffer;
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
codePoints,
|
||||
);
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -57,13 +70,13 @@ export class ExcalidrawFont implements Font {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
codePoints,
|
||||
);
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
return base64;
|
||||
}
|
||||
|
||||
// response not ok, try to continue
|
||||
@@ -89,6 +102,48 @@ export class ExcalidrawFont implements Font {
|
||||
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
|
||||
*
|
||||
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
|
||||
* @param codePoints codepoints used to subset the glyphs
|
||||
*
|
||||
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
|
||||
*/
|
||||
private static async subsetGlyphsByCodePoints(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
codePoints: ReadonlySet<number>,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
|
||||
const { compress, decompress } = await loadWoff2();
|
||||
const { subset } = await loadHbSubset();
|
||||
|
||||
const decompressedBinary = decompress(arrayBuffer).buffer;
|
||||
const subsetSnft = subset(decompressedBinary, codePoints);
|
||||
const compressedBinary = compress(subsetSnft.buffer);
|
||||
|
||||
return ExcalidrawFont.toBase64(compressedBinary.buffer);
|
||||
} catch (e) {
|
||||
console.error("Skipped glyph subsetting", e);
|
||||
// Fallback to encoding whole font in case of errors
|
||||
return ExcalidrawFont.toBase64(arrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static async toBase64(arrayBuffer: ArrayBuffer) {
|
||||
let base64: string;
|
||||
|
||||
if (typeof Buffer !== "undefined") {
|
||||
// node + server-side
|
||||
base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
} else {
|
||||
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
|
||||
}
|
||||
|
||||
return `data:font/woff2;base64,${base64}`;
|
||||
}
|
||||
|
||||
private static createUrls(uri: string): URL[] {
|
||||
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
||||
// no url for local fonts
|
||||
@@ -118,15 +173,14 @@ export class ExcalidrawFont implements Font {
|
||||
}
|
||||
|
||||
// fallback url for bundled fonts
|
||||
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
|
||||
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static getFormat(url: URL) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const parts = pathname.split(".");
|
||||
const parts = new URL(url).pathname.split(".");
|
||||
|
||||
if (parts.length === 1) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js
|
||||
*
|
||||
* CHANGELOG:
|
||||
* - removed dependency on node APIs to work inside the browser
|
||||
* - removed dependency on font fontverter for brotli compression
|
||||
* - removed dependencies on lodash and p-limit
|
||||
* - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now)
|
||||
* - replaced text input with codepoints
|
||||
* - rewritten in typescript and with esm modules
|
||||
|
||||
Copyright (c) 2012, Andreas Lind Petersen
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of the author nor the names of contributors may
|
||||
be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
// function HB_TAG(str) {
|
||||
// return str.split("").reduce((a, ch) => {
|
||||
// return (a << 8) + ch.charCodeAt(0);
|
||||
// }, 0);
|
||||
// }
|
||||
|
||||
function subset(
|
||||
hbSubsetWasm: any,
|
||||
heapu8: Uint8Array,
|
||||
font: ArrayBuffer,
|
||||
codePoints: ReadonlySet<number>,
|
||||
) {
|
||||
const input = hbSubsetWasm.hb_subset_input_create_or_fail();
|
||||
if (input === 0) {
|
||||
throw new Error(
|
||||
"hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure",
|
||||
);
|
||||
}
|
||||
|
||||
const fontBuffer = hbSubsetWasm.malloc(font.byteLength);
|
||||
heapu8.set(new Uint8Array(font), fontBuffer);
|
||||
|
||||
// Create the face
|
||||
const blob = hbSubsetWasm.hb_blob_create(
|
||||
fontBuffer,
|
||||
font.byteLength,
|
||||
2, // HB_MEMORY_MODE_WRITABLE
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const face = hbSubsetWasm.hb_face_create(blob, 0);
|
||||
hbSubsetWasm.hb_blob_destroy(blob);
|
||||
|
||||
// Do the equivalent of --font-features=*
|
||||
const layoutFeatures = hbSubsetWasm.hb_subset_input_set(
|
||||
input,
|
||||
6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
|
||||
);
|
||||
hbSubsetWasm.hb_set_clear(layoutFeatures);
|
||||
hbSubsetWasm.hb_set_invert(layoutFeatures);
|
||||
|
||||
// if (preserveNameIds) {
|
||||
// const inputNameIds = harfbuzzJsWasm.hb_subset_input_set(
|
||||
// input,
|
||||
// 4, // HB_SUBSET_SETS_NAME_ID
|
||||
// );
|
||||
// for (const nameId of preserveNameIds) {
|
||||
// harfbuzzJsWasm.hb_set_add(inputNameIds, nameId);
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (noLayoutClosure) {
|
||||
// harfbuzzJsWasm.hb_subset_input_set_flags(
|
||||
// input,
|
||||
// harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE
|
||||
// );
|
||||
// }
|
||||
|
||||
// Add unicodes indices
|
||||
const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input);
|
||||
for (const c of codePoints) {
|
||||
hbSubsetWasm.hb_set_add(inputUnicodes, c);
|
||||
}
|
||||
|
||||
// if (variationAxes) {
|
||||
// for (const [axisName, value] of Object.entries(variationAxes)) {
|
||||
// if (typeof value === "number") {
|
||||
// // Simple case: Pin/instance the variation axis to a single value
|
||||
// if (
|
||||
// !harfbuzzJsWasm.hb_subset_input_pin_axis_location(
|
||||
// input,
|
||||
// face,
|
||||
// HB_TAG(axisName),
|
||||
// value,
|
||||
// )
|
||||
// ) {
|
||||
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||
// harfbuzzJsWasm.free(fontBuffer);
|
||||
// throw new Error(
|
||||
// `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`,
|
||||
// );
|
||||
// }
|
||||
// } else if (value && typeof value === "object") {
|
||||
// // Complex case: Reduce the variation space of the axis
|
||||
// if (
|
||||
// typeof value.min === "undefined" ||
|
||||
// typeof value.max === "undefined"
|
||||
// ) {
|
||||
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||
// harfbuzzJsWasm.free(fontBuffer);
|
||||
// throw new Error(
|
||||
// `${axisName}: You must provide both a min and a max value when setting the axis range`,
|
||||
// );
|
||||
// }
|
||||
// if (
|
||||
// !harfbuzzJsWasm.hb_subset_input_set_axis_range(
|
||||
// input,
|
||||
// face,
|
||||
// HB_TAG(axisName),
|
||||
// value.min,
|
||||
// value.max,
|
||||
// // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary
|
||||
// value.default ?? NaN,
|
||||
// )
|
||||
// ) {
|
||||
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||
// harfbuzzJsWasm.free(fontBuffer);
|
||||
// throw new Error(
|
||||
// `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
let subset;
|
||||
try {
|
||||
subset = hbSubsetWasm.hb_subset_or_fail(face, input);
|
||||
if (subset === 0) {
|
||||
hbSubsetWasm.hb_face_destroy(face);
|
||||
hbSubsetWasm.free(fontBuffer);
|
||||
throw new Error(
|
||||
"hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
hbSubsetWasm.hb_subset_input_destroy(input);
|
||||
}
|
||||
|
||||
// Get result blob
|
||||
const result = hbSubsetWasm.hb_face_reference_blob(subset);
|
||||
|
||||
const offset = hbSubsetWasm.hb_blob_get_data(result, 0);
|
||||
const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result);
|
||||
if (subsetByteLength === 0) {
|
||||
hbSubsetWasm.hb_blob_destroy(result);
|
||||
hbSubsetWasm.hb_face_destroy(subset);
|
||||
hbSubsetWasm.hb_face_destroy(face);
|
||||
hbSubsetWasm.free(fontBuffer);
|
||||
throw new Error(
|
||||
"Failed to create subset font, maybe the input file is corrupted?",
|
||||
);
|
||||
}
|
||||
|
||||
const subsetFont = new Uint8Array(
|
||||
heapu8.subarray(offset, offset + subsetByteLength),
|
||||
);
|
||||
|
||||
// Clean up
|
||||
hbSubsetWasm.hb_blob_destroy(result);
|
||||
hbSubsetWasm.hb_face_destroy(subset);
|
||||
hbSubsetWasm.hb_face_destroy(face);
|
||||
hbSubsetWasm.free(fontBuffer);
|
||||
|
||||
return subsetFont;
|
||||
}
|
||||
|
||||
export default {
|
||||
subset,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Lazy loads wasm and respective bindings for font subsetting based on the harfbuzzjs.
|
||||
*/
|
||||
let loadedWasm: ReturnType<typeof load> | null = null;
|
||||
|
||||
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
|
||||
const load = (): Promise<{
|
||||
subset: (
|
||||
fontBuffer: ArrayBuffer,
|
||||
codePoints: ReadonlySet<number>,
|
||||
) => Uint8Array;
|
||||
}> => {
|
||||
return new Promise(async (resolve) => {
|
||||
const [binary, bindings] = await Promise.all([
|
||||
import("./hb-subset.wasm"),
|
||||
import("./hb-subset.bindings"),
|
||||
]);
|
||||
|
||||
WebAssembly.instantiate(binary.default).then((module) => {
|
||||
const harfbuzzJsWasm = module.instance.exports;
|
||||
// @ts-expect-error since `.buffer` is custom prop
|
||||
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
|
||||
|
||||
const hbSubset = {
|
||||
subset: (fontBuffer: ArrayBuffer, codePoints: ReadonlySet<number>) => {
|
||||
return bindings.default.subset(
|
||||
harfbuzzJsWasm,
|
||||
heapu8,
|
||||
fontBuffer,
|
||||
codePoints,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
resolve(hbSubset);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// lazy load the default export
|
||||
export default (): ReturnType<typeof load> => {
|
||||
if (!loadedWasm) {
|
||||
loadedWasm = load();
|
||||
}
|
||||
|
||||
return loadedWasm;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Lazy loads wasm and respective bindings for woff2 compression and decompression.
|
||||
*/
|
||||
type Vector = any;
|
||||
|
||||
let loadedWasm: ReturnType<typeof load> | null = null;
|
||||
|
||||
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
|
||||
const load = (): Promise<{
|
||||
compress: (buffer: ArrayBuffer) => Uint8Array;
|
||||
decompress: (buffer: ArrayBuffer) => Uint8Array;
|
||||
}> => {
|
||||
return new Promise(async (resolve) => {
|
||||
const [binary, bindings] = await Promise.all([
|
||||
import("./woff2.wasm"),
|
||||
import("./woff2.bindings"),
|
||||
]);
|
||||
|
||||
// initializing the module manually, so that we could pass in the wasm binary
|
||||
bindings
|
||||
.default({ wasmBinary: binary.default })
|
||||
.then(
|
||||
(module: {
|
||||
woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector;
|
||||
woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector;
|
||||
}) => {
|
||||
// re-map from internal vector into byte array
|
||||
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
|
||||
const arr = [];
|
||||
for (let i = 0, l = vector.size(); i < l; i++) {
|
||||
arr.push(vector.get(i));
|
||||
}
|
||||
|
||||
return new Uint8Array(arr);
|
||||
}
|
||||
|
||||
// re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings)
|
||||
const woff2 = {
|
||||
compress: (buffer: ArrayBuffer) =>
|
||||
convertFromVecToUint8Array(
|
||||
module.woff2Enc(buffer, buffer.byteLength),
|
||||
),
|
||||
decompress: (buffer: ArrayBuffer) =>
|
||||
convertFromVecToUint8Array(
|
||||
module.woff2Dec(buffer, buffer.byteLength),
|
||||
),
|
||||
};
|
||||
|
||||
resolve(woff2);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// lazy loaded default export
|
||||
export default (): ReturnType<typeof load> => {
|
||||
if (!loadedWasm) {
|
||||
loadedWasm = load();
|
||||
}
|
||||
|
||||
return loadedWasm;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
||||
},
|
||||
files: null,
|
||||
renderEmbeddables: false,
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -40,6 +41,7 @@ export const useLibraryItemSvg = (
|
||||
// When there is no svg in cache export it and save to cache
|
||||
(async () => {
|
||||
const exportedSvg = await exportLibraryItemToSvg(elements);
|
||||
// TODO: should likely be removed for custom fonts
|
||||
exportedSvg.querySelector(".style-fonts")?.remove();
|
||||
|
||||
if (exportedSvg) {
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||
"publishLibrary": "Publish your own library",
|
||||
"bindTextToElement": "Press enter to add text",
|
||||
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
|
||||
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
|
||||
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
|
||||
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
|
||||
@@ -366,6 +367,8 @@
|
||||
"click": "click",
|
||||
"deepSelect": "Deep select",
|
||||
"deepBoxSelect": "Deep select within box, and prevent dragging",
|
||||
"createFlowchart": "Create a flowchart from a generic element",
|
||||
"navigateFlowchart": "Navigate a flowchart",
|
||||
"curvedArrow": "Curved arrow",
|
||||
"curvedLine": "Curved line",
|
||||
"documentation": "Documentation",
|
||||
|
||||
@@ -113,6 +113,8 @@
|
||||
"esbuild-sass-plugin": "2.16.0",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"fonteditor-core": "2.4.1",
|
||||
"harfbuzzjs": "0.3.6",
|
||||
"import-meta-loader": "1.1.0",
|
||||
"mini-css-extract-plugin": "2.6.1",
|
||||
"postcss-loader": "7.0.1",
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
} from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
@@ -104,6 +105,7 @@ export const getRenderOpacity = (
|
||||
element: ExcalidrawElement,
|
||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||
elementsPendingErasure: ElementsPendingErasure,
|
||||
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||
) => {
|
||||
// multiplying frame opacity with element opacity to combine them
|
||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||
@@ -113,6 +115,7 @@ export const getRenderOpacity = (
|
||||
// (so that erasing always results in lower opacity than original)
|
||||
if (
|
||||
elementsPendingErasure.has(element.id) ||
|
||||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||
) {
|
||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||
@@ -672,6 +675,7 @@ export const renderElement = (
|
||||
element,
|
||||
getContainingFrame(element, elementsMap),
|
||||
renderConfig.elementsPendingErasure,
|
||||
renderConfig.pendingFlowchartNodes,
|
||||
);
|
||||
|
||||
switch (element.type) {
|
||||
|
||||
@@ -370,6 +370,23 @@ const _renderStaticScene = ({
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// render pending nodes for flowcharts
|
||||
renderConfig.pendingFlowchartNodes?.forEach((element) => {
|
||||
try {
|
||||
renderElement(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
rc,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** throttled to animation framerate */
|
||||
|
||||
@@ -377,6 +377,10 @@ class Scene {
|
||||
}
|
||||
|
||||
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(index) || index < 0) {
|
||||
throw new Error(
|
||||
"insertElementAtIndex can only be called with index >= 0",
|
||||
@@ -403,7 +407,11 @@ class Scene {
|
||||
};
|
||||
|
||||
insertElements = (elements: ExcalidrawElement[]) => {
|
||||
const index = elements[0].frameId
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = elements[0]?.frameId
|
||||
? this.getElementIndex(elements[0].frameId)
|
||||
: this.elements.length;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { RenderableElementsMap } from "./types";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { renderStaticScene } from "../renderer/staticScene";
|
||||
import { Fonts } from "../fonts";
|
||||
import type { Font } from "../fonts/ExcalidrawFont";
|
||||
|
||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@@ -242,6 +243,7 @@ export const exportToCanvas = async (
|
||||
// empty disables embeddable rendering
|
||||
embedsValidationStatus: new Map(),
|
||||
elementsPendingErasure: new Set(),
|
||||
pendingFlowchartNodes: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -354,50 +356,14 @@ export const exportToSvg = async (
|
||||
</clipPath>`;
|
||||
}
|
||||
|
||||
const fontFamilies = elements.reduce((acc, element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.add(element.fontFamily);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Set<number>());
|
||||
|
||||
const fontFaces = opts?.skipInliningFonts
|
||||
? []
|
||||
: await Promise.all(
|
||||
Array.from(fontFamilies).map(async (x) => {
|
||||
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
||||
|
||||
if (!Array.isArray(fonts)) {
|
||||
console.error(
|
||||
`Couldn't find registered fonts for font-family "${x}"`,
|
||||
Fonts.registered,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata?.local) {
|
||||
// don't inline local fonts
|
||||
return;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
fonts.map(
|
||||
async (font) => `@font-face {
|
||||
font-family: ${font.fontFace.family};
|
||||
src: url(${await font.getContent()});
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
|
||||
|
||||
svgRoot.innerHTML = `
|
||||
${SVG_EXPORT_TAG}
|
||||
${metadata}
|
||||
<defs>
|
||||
<style class="style-fonts">
|
||||
${fontFaces.flat().filter(Boolean).join("\n")}
|
||||
${fontFaces.join("\n")}
|
||||
</style>
|
||||
${exportingFrameClipPath}
|
||||
</defs>
|
||||
@@ -468,3 +434,67 @@ export const getExportSize = (
|
||||
|
||||
return [width, height];
|
||||
};
|
||||
|
||||
const getFontFaces = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Promise<string[]> => {
|
||||
const fontFamilies = new Set<number>();
|
||||
const codePoints = new Set<number>();
|
||||
|
||||
for (const element of elements) {
|
||||
if (!isTextElement(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fontFamilies.add(element.fontFamily);
|
||||
|
||||
// gather unique codepoints only when inlining fonts
|
||||
for (const codePoint of Array.from(element.originalText, (u) =>
|
||||
u.codePointAt(0),
|
||||
)) {
|
||||
if (codePoint) {
|
||||
codePoints.add(codePoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSource = (font: Font) => {
|
||||
try {
|
||||
// retrieve font source as dataurl based on the used codepoints
|
||||
return font.getContent(codePoints);
|
||||
} catch {
|
||||
// fallback to font source as a url
|
||||
return font.urls[0].toString();
|
||||
}
|
||||
};
|
||||
|
||||
const fontFaces = await Promise.all(
|
||||
Array.from(fontFamilies).map(async (x) => {
|
||||
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
||||
|
||||
if (!Array.isArray(fonts)) {
|
||||
console.error(
|
||||
`Couldn't find registered fonts for font-family "${x}"`,
|
||||
Fonts.registered,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (metadata?.local) {
|
||||
// don't inline local fonts
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
fonts.map(
|
||||
async (font) => `@font-face {
|
||||
font-family: ${font.fontFace.family};
|
||||
src: url(${await getSource(font)});
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return fontFaces.flat();
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
Device,
|
||||
PendingExcalidrawElements,
|
||||
} from "../types";
|
||||
import type { MakeBrand } from "../utility-types";
|
||||
|
||||
@@ -33,6 +34,7 @@ export type StaticCanvasRenderConfig = {
|
||||
isExporting: boolean;
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
elementsPendingErasure: ElementsPendingErasure;
|
||||
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
||||
};
|
||||
|
||||
export type SVGRenderConfig = {
|
||||
|
||||
@@ -246,6 +246,20 @@ describe("z-index manipulation", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// elements should not duplicate
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "A", containerId: "C" },
|
||||
{ id: "B" },
|
||||
{ id: "C", isSelected: true },
|
||||
],
|
||||
operations: [
|
||||
[actionSendBackward, ["A", "C", "B"]],
|
||||
// noop
|
||||
[actionSendBackward, ["A", "C", "B"]],
|
||||
],
|
||||
});
|
||||
|
||||
// grouped elements should be atomic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -648,6 +648,7 @@ export type AppClassProperties = {
|
||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||
getName: App["getName"];
|
||||
dismissLinearEditor: App["dismissLinearEditor"];
|
||||
flowChartCreator: App["flowChartCreator"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -828,3 +829,5 @@ export type EmbedsValidationStatus = Map<
|
||||
>;
|
||||
|
||||
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||||
|
||||
export type PendingExcalidrawElements = ExcalidrawElement[];
|
||||
|
||||
@@ -929,6 +929,12 @@ export const assertNever = (
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export function invariant(condition: any, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes on values of `opts` object (strict equality).
|
||||
*/
|
||||
|
||||
@@ -80,29 +80,37 @@ const getTargetIndexAccountingForBinding = (
|
||||
direction: "left" | "right",
|
||||
) => {
|
||||
if ("containerId" in nextElement && nextElement.containerId) {
|
||||
if (direction === "left") {
|
||||
const containerElement = Scene.getScene(nextElement)!.getElement(
|
||||
nextElement.containerId,
|
||||
);
|
||||
if (containerElement) {
|
||||
return elements.indexOf(containerElement);
|
||||
}
|
||||
} else {
|
||||
return elements.indexOf(nextElement);
|
||||
const containerElement = Scene.getScene(nextElement)!.getElement(
|
||||
nextElement.containerId,
|
||||
);
|
||||
if (containerElement) {
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
elements.indexOf(containerElement),
|
||||
elements.indexOf(nextElement),
|
||||
)
|
||||
: Math.max(
|
||||
elements.indexOf(containerElement),
|
||||
elements.indexOf(nextElement),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const boundElementId = nextElement.boundElements?.find(
|
||||
(binding) => binding.type !== "arrow",
|
||||
)?.id;
|
||||
if (boundElementId) {
|
||||
if (direction === "left") {
|
||||
return elements.indexOf(nextElement);
|
||||
}
|
||||
|
||||
const boundTextElement =
|
||||
Scene.getScene(nextElement)!.getElement(boundElementId);
|
||||
if (boundTextElement) {
|
||||
return elements.indexOf(boundTextElement);
|
||||
return direction === "left"
|
||||
? Math.min(
|
||||
elements.indexOf(boundTextElement),
|
||||
elements.indexOf(nextElement),
|
||||
)
|
||||
: Math.max(
|
||||
elements.indexOf(boundTextElement),
|
||||
elements.indexOf(nextElement),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* This script is used to convert the wasm modules into js modules, with the binary converted into base64 encoded strings.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const wasmModules = [
|
||||
{
|
||||
pkg: `../node_modules/fonteditor-core`,
|
||||
src: `./wasm/woff2.wasm`,
|
||||
dest: `../packages/excalidraw/fonts/wasm/woff2.wasm.ts`,
|
||||
},
|
||||
{
|
||||
pkg: `../node_modules/harfbuzzjs`,
|
||||
src: `./wasm/hb-subset.wasm`,
|
||||
dest: `../packages/excalidraw/fonts/wasm/hb-subset.wasm.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { pkg, src, dest } of wasmModules) {
|
||||
const packagePath = path.resolve(__dirname, pkg, "package.json");
|
||||
const licensePath = path.resolve(__dirname, pkg, "LICENSE");
|
||||
const sourcePath = path.resolve(__dirname, src);
|
||||
const destPath = path.resolve(__dirname, dest);
|
||||
|
||||
const {
|
||||
name,
|
||||
version,
|
||||
author,
|
||||
license,
|
||||
authors,
|
||||
licenses,
|
||||
} = require(packagePath);
|
||||
|
||||
const licenseContent = fs.readFileSync(licensePath, "utf-8") || "";
|
||||
const base64 = fs.readFileSync(sourcePath, "base64");
|
||||
const content = `// GENERATED CODE -- DO NOT EDIT!
|
||||
/* eslint-disable prettier/prettier */
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* The following wasm module is generated with \`scripts/buildWasm.js\` and encoded as base64.
|
||||
*
|
||||
* The source of this content is taken from the package "${name}", which contains the following metadata:
|
||||
*
|
||||
* @author ${author || JSON.stringify(authors)}
|
||||
* @license ${license || JSON.stringify(licenses)}
|
||||
* @version ${version}
|
||||
|
||||
${licenseContent}
|
||||
*/
|
||||
|
||||
// faster atob alternative - https://github.com/evanw/esbuild/issues/1534#issuecomment-902738399
|
||||
const __toBinary = /* @__PURE__ */ (() => {
|
||||
const table = new Uint8Array(128);
|
||||
for (let i = 0; i < 64; i++)
|
||||
{table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;}
|
||||
return (base64) => {
|
||||
const n = base64.length; const bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0);
|
||||
for (let i2 = 0, j = 0; i2 < n; ) {
|
||||
const c0 = table[base64.charCodeAt(i2++)]; const c1 = table[base64.charCodeAt(i2++)];
|
||||
const c2 = table[base64.charCodeAt(i2++)]; const c3 = table[base64.charCodeAt(i2++)];
|
||||
bytes[j++] = c0 << 2 | c1 >> 4;
|
||||
bytes[j++] = c1 << 4 | c2 >> 2;
|
||||
bytes[j++] = c2 << 6 | c3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
})();
|
||||
|
||||
export default __toBinary(\`${base64}\`);
|
||||
`;
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0:
|
||||
dependencies:
|
||||
"@xmldom/xmldom" "^0.8.3"
|
||||
|
||||
fonteditor-core@2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/fonteditor-core/-/fonteditor-core-2.4.1.tgz#ff4b3cd04b50f98026bedad353d0ef6692464bc9"
|
||||
integrity sha512-nKDDt6kBQGq665tQO5tCRQUClJG/2MAF9YT1eKHl+I4NasdSb6DgXrv/gMjNxjo9NyaVEv9KU9VZxLHMstN1wg==
|
||||
dependencies:
|
||||
"@xmldom/xmldom" "^0.8.3"
|
||||
|
||||
for-each@^0.3.3:
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
|
||||
@@ -6457,6 +6464,11 @@ hachure-fill@^0.5.2:
|
||||
resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc"
|
||||
integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==
|
||||
|
||||
harfbuzzjs@0.3.6:
|
||||
version "0.3.6"
|
||||
resolved "https://registry.yarnpkg.com/harfbuzzjs/-/harfbuzzjs-0.3.6.tgz#97865c861aa7734af5bd1904570712e9d753fda9"
|
||||
integrity sha512-dzf7y6NS8fiAIvPAL/VKwY8wx2HCzUB0vUfOo6h1J5UilFEEf7iYqFsvgwjHwvM3whbjfOMadNvQekU3KuRnWQ==
|
||||
|
||||
has-ansi@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
|
||||
|
||||
Reference in New Issue
Block a user