feat: in canvas links between shapes (#8812)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>pull/8853/head
parent
a758aaf8f6
commit
c0b80a03bd
@ -0,0 +1,105 @@
|
|||||||
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
|
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||||
|
import {
|
||||||
|
canCreateLinkFromElements,
|
||||||
|
defaultGetElementLinkFromSelection,
|
||||||
|
getLinkIdAndTypeFromSelection,
|
||||||
|
} from "../element/elementLink";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionCopyElementLink = register({
|
||||||
|
name: "copyElementLink",
|
||||||
|
label: "labels.copyElementLink",
|
||||||
|
icon: copyIcon,
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.location) {
|
||||||
|
const idAndType = getLinkIdAndTypeFromSelection(
|
||||||
|
selectedElements,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idAndType) {
|
||||||
|
await copyTextToSystemClipboard(
|
||||||
|
app.props.generateLinkForSelection
|
||||||
|
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
|
||||||
|
: defaultGetElementLinkFromSelection(
|
||||||
|
idAndType.id,
|
||||||
|
idAndType.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
toast: {
|
||||||
|
message: t("toast.elementLinkCopied"),
|
||||||
|
closable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
app,
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
app,
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
predicate: (elements, appState) =>
|
||||||
|
canCreateLinkFromElements(getSelectedElements(elements, appState)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionLinkToElement = register({
|
||||||
|
name: "linkToElement",
|
||||||
|
label: "labels.linkToElement",
|
||||||
|
icon: elementLinkIcon,
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElements.length !== 1 ||
|
||||||
|
!canCreateLinkFromElements(selectedElements)
|
||||||
|
) {
|
||||||
|
return { elements, appState, app, storeAction: StoreAction.NONE };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
openDialog: {
|
||||||
|
name: "elementLinkSelector",
|
||||||
|
sourceElementId: getSelectedElements(elements, appState)[0].id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
predicate: (elements, appState, appProps, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
canCreateLinkFromElements(selectedElements)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
trackEvent: false,
|
||||||
|
});
|
@ -0,0 +1,87 @@
|
|||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.ElementLinkDialog {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--editor-container-padding);
|
||||||
|
left: calc(var(--editor-container-padding) * 4);
|
||||||
|
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
background-color: var(--island-bg-color);
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
left: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__header {
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__input {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.ElementLinkDialog__input-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__remove {
|
||||||
|
color: $oc-red-9;
|
||||||
|
margin-left: 1rem;
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon svg {
|
||||||
|
color: $oc-red-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ElementLinkDialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
|
||||||
|
@include isMobile {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,174 @@
|
|||||||
|
import { TextField } from "./TextField";
|
||||||
|
import type { AppProps, AppState, UIAppState } from "../types";
|
||||||
|
import DialogActionButton from "./DialogActionButton";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import {
|
||||||
|
defaultGetElementLinkFromSelection,
|
||||||
|
getLinkIdAndTypeFromSelection,
|
||||||
|
} from "../element/elementLink";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import type { ElementsMap, ExcalidrawElement } from "../element/types";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { TrashIcon } from "./icons";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
|
import "./ElementLinkDialog.scss";
|
||||||
|
import { normalizeLink } from "../data/url";
|
||||||
|
|
||||||
|
const ElementLinkDialog = ({
|
||||||
|
sourceElementId,
|
||||||
|
onClose,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
generateLinkForSelection = defaultGetElementLinkFromSelection,
|
||||||
|
}: {
|
||||||
|
sourceElementId: ExcalidrawElement["id"];
|
||||||
|
elementsMap: ElementsMap;
|
||||||
|
appState: UIAppState;
|
||||||
|
onClose?: () => void;
|
||||||
|
generateLinkForSelection: AppProps["generateLinkForSelection"];
|
||||||
|
}) => {
|
||||||
|
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
|
||||||
|
|
||||||
|
const [nextLink, setNextLink] = useState<string | null>(originalLink);
|
||||||
|
const [linkEdited, setLinkEdited] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedElements = getSelectedElements(elementsMap, appState);
|
||||||
|
let nextLink = originalLink;
|
||||||
|
|
||||||
|
if (selectedElements.length > 0 && generateLinkForSelection) {
|
||||||
|
const idAndType = getLinkIdAndTypeFromSelection(
|
||||||
|
selectedElements,
|
||||||
|
appState as AppState,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (idAndType) {
|
||||||
|
nextLink = normalizeLink(
|
||||||
|
generateLinkForSelection(idAndType.id, idAndType.type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextLink(nextLink);
|
||||||
|
}, [
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
appState.selectedElementIds,
|
||||||
|
originalLink,
|
||||||
|
generateLinkForSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
|
||||||
|
const elementToLink = elementsMap.get(sourceElementId);
|
||||||
|
elementToLink &&
|
||||||
|
mutateElement(elementToLink, {
|
||||||
|
link: nextLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextLink && linkEdited && sourceElementId) {
|
||||||
|
const elementToLink = elementsMap.get(sourceElementId);
|
||||||
|
elementToLink &&
|
||||||
|
mutateElement(elementToLink, {
|
||||||
|
link: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose?.();
|
||||||
|
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
|
event.key === KEYS.ENTER
|
||||||
|
) {
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.openDialog?.name === "elementLinkSelector" &&
|
||||||
|
event.key === KEYS.ESCAPE
|
||||||
|
) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [appState, onClose, handleConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ElementLinkDialog">
|
||||||
|
<div className="ElementLinkDialog__header">
|
||||||
|
<h2>{t("elementLink.title")}</h2>
|
||||||
|
<p>{t("elementLink.desc")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ElementLinkDialog__input">
|
||||||
|
<TextField
|
||||||
|
value={nextLink ?? ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!linkEdited) {
|
||||||
|
setLinkEdited(true);
|
||||||
|
}
|
||||||
|
setNextLink(value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === KEYS.ENTER) {
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ElementLinkDialog__input-field"
|
||||||
|
selectOnRender
|
||||||
|
/>
|
||||||
|
|
||||||
|
{originalLink && nextLink && (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.remove")}
|
||||||
|
aria-label={t("buttons.remove")}
|
||||||
|
label={t("buttons.remove")}
|
||||||
|
onClick={() => {
|
||||||
|
// removes the link from the input
|
||||||
|
// but doesn't update the element
|
||||||
|
|
||||||
|
// when confirmed, will remove the link from the element
|
||||||
|
setNextLink(null);
|
||||||
|
setLinkEdited(true);
|
||||||
|
}}
|
||||||
|
className="ElementLinkDialog__remove"
|
||||||
|
icon={TrashIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ElementLinkDialog__actions">
|
||||||
|
<DialogActionButton
|
||||||
|
label={t("buttons.cancel")}
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginRight: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogActionButton
|
||||||
|
label={t("buttons.confirm")}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
actionType="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElementLinkDialog;
|
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Create and link between shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ELEMENT_LINK_KEY } from "../constants";
|
||||||
|
import { normalizeLink } from "../data/url";
|
||||||
|
import { elementsAreInSameGroup } from "../groups";
|
||||||
|
import type { AppProps, AppState } from "../types";
|
||||||
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
export const defaultGetElementLinkFromSelection: Exclude<
|
||||||
|
AppProps["generateLinkForSelection"],
|
||||||
|
undefined
|
||||||
|
> = (id, type) => {
|
||||||
|
const url = window.location.href;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const link = new URL(url);
|
||||||
|
link.searchParams.set(ELEMENT_LINK_KEY, id);
|
||||||
|
|
||||||
|
return normalizeLink(link.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLink(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLinkIdAndTypeFromSelection = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
): {
|
||||||
|
id: string;
|
||||||
|
type: "element" | "group";
|
||||||
|
} | null => {
|
||||||
|
if (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
canCreateLinkFromElements(selectedElements)
|
||||||
|
) {
|
||||||
|
if (selectedElements.length === 1) {
|
||||||
|
return {
|
||||||
|
id: selectedElements[0].id,
|
||||||
|
type: "element",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length > 1) {
|
||||||
|
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
|
||||||
|
|
||||||
|
if (selectedGroupId) {
|
||||||
|
return {
|
||||||
|
id: selectedGroupId,
|
||||||
|
type: "group",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: selectedElements[0].groupIds[0],
|
||||||
|
type: "group",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canCreateLinkFromElements = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
if (selectedElements.length === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isElementLink = (url: string) => {
|
||||||
|
try {
|
||||||
|
const _url = new URL(url);
|
||||||
|
return (
|
||||||
|
_url.searchParams.has(ELEMENT_LINK_KEY) &&
|
||||||
|
_url.host === window.location.host
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseElementLinkFromURL = (url: string) => {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(url);
|
||||||
|
if (searchParams.has(ELEMENT_LINK_KEY)) {
|
||||||
|
const id = searchParams.get(ELEMENT_LINK_KEY);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
Loading…
Reference in New Issue