Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

This commit is contained in:
Daniel J. Geiger 2023-12-02 14:50:21 -06:00
commit 1dfadb4d26
103 changed files with 3748 additions and 968 deletions

View File

@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!

View File

@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL=

View File

@ -26,6 +26,8 @@ import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../src/packages/excalidraw/index";
import {
AppState,
@ -776,6 +778,64 @@ const ExcalidrawWrapper = () => {
)}
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}

View File

@ -9,6 +9,7 @@ import {
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
@ -28,7 +29,7 @@ const alignActionsPredicate = (
return (
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
!selectedElements.some((el) => isFrameLikeElement(el))
);
};

View File

@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
@ -20,7 +20,7 @@ const deleteSelectedElements = (
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
elements.filter((el) => isFrameLikeElement(el)),
appState,
).map((el) => el.id),
);

View File

@ -5,6 +5,7 @@ import {
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
return (
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame")
!selectedElements.some((el) => isFrameLikeElement(el))
);
};

View File

@ -20,7 +20,7 @@ import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
@ -140,11 +140,11 @@ const duplicateElements = (
}
const boundTextElement = getBoundTextElement(element);
const isElementAFrame = isFrameElement(element);
const isElementAFrameLike = isFrameLikeElement(element);
if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) {
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
@ -154,7 +154,7 @@ const duplicateElements = (
sortedElements,
groupId,
).flatMap((element) =>
isFrameElement(element)
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@ -180,7 +180,7 @@ const duplicateElements = (
);
continue;
}
if (isElementAFrame) {
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push(

View File

@ -1,4 +1,5 @@
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
@ -51,7 +52,7 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";

View File

@ -7,23 +7,27 @@ import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.length === 1 && selectedElements[0].type === "frame";
return (
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
);
};
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (selectedFrame && selectedFrame.type === "frame") {
if (isFrameLikeElement(selectedElement)) {
const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements),
selectedFrame.id,
selectedElement.id,
).filter((element) => !(element.type === "text" && element.containerId));
return {
@ -54,15 +58,20 @@ export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0];
const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (selectedFrame && selectedFrame.type === "frame") {
if (isFrameLikeElement(selectedElement)) {
return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
elements: removeAllElementsFromFrame(
elements,
selectedElement,
appState,
),
appState: {
...appState,
selectedElementIds: {
[selectedFrame.id]: true,
[selectedElement.id]: true,
},
},
commitToHistory: true,

View File

@ -22,8 +22,8 @@ import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
getFrameElements,
groupByFrames,
getFrameLikeElements,
groupByFrameLikes,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
@ -102,7 +102,7 @@ export const actionGroup = register({
// when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements);
const frameElementsMap = groupByFrameLikes(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame(
@ -219,7 +219,7 @@ export const actionUngroup = register({
.map((element) => element.frameId!),
);
const targetFrames = getFrameElements(elements).filter((frame) =>
const targetFrames = getFrameLikeElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id),
);

View File

@ -56,13 +56,18 @@ export const actionShortcuts = register({
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") {
if (appState.openDialog?.name === "help") {
focusContainer();
}
return {
appState: {
...appState,
openDialog: appState.openDialog === "help" ? null : "help",
openDialog:
appState.openDialog?.name === "help"
? null
: {
name: "help",
},
},
commitToHistory: false,
};

View File

@ -20,7 +20,7 @@ import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
isFrameElement,
isFrameLikeElement,
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
@ -138,7 +138,7 @@ export const actionPasteStyles = register({
});
}
if (isFrameElement(element)) {
if (isFrameLikeElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
backgroundColor: "transparent",

View File

@ -1,3 +1,7 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
action: string,
@ -5,13 +9,13 @@ export const trackEvent = (
value?: number,
) => {
try {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
@ -19,6 +23,10 @@ export const trackEvent = (
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,

View File

@ -9,7 +9,10 @@ import {
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import {
isFrameLikeElement,
isInitializedImageElement,
} from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({
files: BinaryFiles | null;
}) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
elements.filter((element) => isFrameLikeElement(element)),
);
let foundFile = false;

View File

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
import { t } from "../i18n";
import { useDevice } from "../components/App";
import {
@ -37,8 +37,11 @@ import {
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const SelectedShapeActions = ({
appState,
@ -80,7 +83,8 @@ export const SelectedShapeActions = ({
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null;
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
@ -95,7 +99,8 @@ export const SelectedShapeActions = ({
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
</div>
@ -233,6 +238,8 @@ export const ShapesSwitcher = ({
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@ -333,13 +340,43 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "settings",
source: "settings",
tab: "diagram-to-code",
});
}}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu>
</>

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,11 @@ import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
/** whether button is in active state */

View File

@ -0,0 +1,15 @@
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};

View File

@ -117,7 +117,7 @@ export const JSONExportDialog = ({
return (
<>
{appState.openDialog === "jsonExport" && (
{appState.openDialog?.name === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}

View File

@ -1,7 +1,12 @@
import clsx from "clsx";
import React from "react";
import { ActionManager } from "../actions/manager";
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import {
CLASSES,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_WIDTH,
TOOL_TYPE,
} from "../constants";
import { showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
@ -57,6 +62,8 @@ import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { SubtypeToggles } from "./Subtypes";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
interface LayerUIProps {
actionManager: ActionManager;
@ -78,6 +85,14 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
}
const DefaultMainMenu: React.FC<{
@ -134,6 +149,10 @@ const LayerUI = ({
children,
app,
isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -164,7 +183,7 @@ const LayerUI = ({
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport"
appState.openDialog?.name !== "imageExport"
) {
return null;
}
@ -297,9 +316,11 @@ const LayerUI = ({
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={appState.activeTool.type === "laser"}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: "laser" })
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
@ -378,6 +399,7 @@ const LayerUI = ({
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@ -434,13 +456,32 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog === "help" && (
{appState.openDialog?.name === "help" && (
<HelpDialog
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
{appState.openDialog?.name === "settings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "settings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}

View File

@ -0,0 +1,38 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
const DEFAULT_SIZE: ToolButtonSize = "small";
export const ElementCanvasButton = (props: {
title?: string;
icon: JSX.Element;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
}) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__MagicButton",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">{props.icon}</div>
</label>
);
};

View File

@ -0,0 +1,18 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}

View File

@ -0,0 +1,160 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
color: "#000",
background: "pink",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
{/* <h2
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
While the OpenAI API is in beta, its use is strictly limited as
such we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<Paragraph>
By default, your API token is not persisted anywhere so you'll need
to insert it again after reload. But, you can persist locally in
your browser below.
</Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to
turn it into code. This dialog can be accessed using the{" "}
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
};

View File

@ -1,221 +0,0 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View File

@ -1,243 +0,0 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;

View File

@ -18,8 +18,11 @@
overflow: auto;
padding: calc(var(--space-factor) * 10);
display: flex;
flex-direction: column;
.Island {
padding: 2.5rem !important;
padding: 2.5rem;
}
}

View File

@ -47,7 +47,7 @@ export const ExportToImage = () => {
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: "imageExport" });
setAppState({ openDialog: { name: "imageExport" } });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}

View File

@ -0,0 +1,10 @@
export const Paragraph = (props: {
children: React.ReactNode;
style?: React.CSSProperties;
}) => {
return (
<p className="excalidraw__paragraph" style={props.style}>
{props.children}
</p>
);
};

View File

@ -113,7 +113,7 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements);
trackEvent("magic", "chart", chartType);
trackEvent("paste", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {

View File

@ -8,6 +8,7 @@ import Trans from "./Trans";
import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils";
import {
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
MIME_TYPES,
@ -19,6 +20,7 @@ import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss";
@ -31,34 +33,6 @@ interface PublishLibraryDataParams {
website: string;
}
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128;
@ -255,7 +229,9 @@ const PublishLibrary = ({
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const data = importPublishLibDataFromStorage();
const data = EditorLocalStorage.get<PublishLibraryDataParams>(
EDITOR_LS_KEYS.PUBLISH_LIBRARY,
);
if (data) {
setLibraryData(data);
}
@ -328,7 +304,7 @@ const PublishLibrary = ({
if (response.ok) {
return response.json().then(({ url }) => {
// flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
onSuccess({
url,
authorName: libraryData.authorName,
@ -384,7 +360,7 @@ const PublishLibrary = ({
const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData);
EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);

View File

@ -4,6 +4,7 @@ import { Action, makeCustomActionName } from "../actions/types";
import clsx from "clsx";
import {
Subtype,
SubtypeRecord,
getSubtypeNames,
hasAlwaysEnabledActions,
isSubtypeAction,
@ -21,7 +22,7 @@ import { Island } from "./Island";
export const SubtypeButton = (
subtype: Subtype,
parentType: ExcalidrawElement["type"],
parentType: SubtypeRecord["parents"][number],
icon: ({ theme }: { theme: Theme }) => JSX.Element,
key?: string,
) => {

View File

@ -0,0 +1,10 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}

View File

@ -0,0 +1,133 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../../types";
import { useApp } from "../App";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { ArrowRightIcon } from "../icons";
import "./MermaidToExcalidraw.scss";
import { t } from "../../i18n";
import Trans from "../Trans";
import {
LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW,
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
}) => {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState<Error | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
}).catch(() => {});
}, [deferredText, mermaidToExcalidrawLib]);
const textRef = useRef(text);
// slightly hacky but really quite simple
// essentially, we want to save the text to LS when the component unmounts
useEffect(() => {
textRef.current = text;
}, [text]);
useEffect(() => {
return () => {
if (textRef.current) {
saveMermaidDataToStorage(textRef.current);
}
};
}, []);
return (
<>
<div className="ttd-dialog-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput
input={text}
placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)}
/>
</TTDDialogPanel>
<TTDDialogPanel
label={t("mermaid.preview")}
panelAction={{
action: () => {
insertToEditor({
app,
data,
text,
shouldSaveMermaidDataToStorage: true,
});
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;

View File

@ -0,0 +1,301 @@
@import "../../css/variables.module";
$verticalBreakpoint: 861px;
.excalidraw {
.Modal.Dialog.ttd-dialog {
padding: 1.25rem;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
box-shadow: none;
}
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
}
.Dialog__content {
flex: 1 1 auto;
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin-bottom: 1.5rem;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
border-bottom: 2px solid var(--color-primary);
}
}
.ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem;
padding-inline: 2.5rem;
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
&[hidden] {
display: none;
}
}
.ttd-dialog-input {
width: auto;
height: 10rem;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-output-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
height: 400px;
width: auto;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.ttd-dialog-output-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
.ttd-dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
&__header {
display: flex;
margin: 0px 4px 4px 4px;
align-items: center;
gap: 1rem;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
}
&:first-child {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 4rem;
}
}
@media screen and (min-width: $verticalBreakpoint) {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
}
}
textarea {
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
.ttd-dialog-panel-button-container {
margin-top: 1rem;
margin-bottom: 0.5rem;
&.invisible {
.ttd-dialog-panel-button {
display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
}
}
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
position: relative;
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .excalidraw.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View File

@ -0,0 +1,350 @@
import { Dialog } from "../Dialog";
import { useApp } from "../App";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TTDDialogTabs from "./TTDDialogTabs";
import { ChangeEventHandler, useEffect, useRef, useState } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab";
import { t } from "../../i18n";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string | undefined; error?: null | undefined }
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export const TTDDialog = (
props:
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true },
) => {
const appState = useUIAppState();
if (appState.openDialog?.name !== "ttd") {
return null;
}
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
};
/**
* Text to diagram (TTD) dialog
*/
export const TTDDialogBase = withInternalFallback(
"TTDDialogBase",
({
tab,
...rest
}: {
tab: "text-to-diagram" | "mermaid";
} & (
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true }
)) => {
const app = useApp();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
event,
) => {
setText(event.target.value);
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0 ||
// means this is not a text-to-diagram dialog (needed for TS only)
"__fallback" in rest
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await rest.onTextSubmit(prompt);
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
saveMermaidDataToStorage(generatedResponse);
} catch (error: any) {
console.info(
`%cTTD mermaid render errror: ${error.message}`,
"color: red",
);
console.info(
`>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
"color: yellow",
);
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
loaded: false,
api: import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
),
});
useEffect(() => {
const fn = async () => {
await mermaidToExcalidrawLib.api;
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
};
fn();
}, [mermaidToExcalidrawLib.api]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
return (
<Dialog
className="ttd-dialog"
onCloseRequest={() => {
app.setOpenDialog(null);
}}
size={1200}
title={false}
{...rest}
autofocus={false}
>
<TTDDialogTabs dialog="ttd" tab={tab}>
{"__fallback" in rest && rest.__fallback ? (
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
) : (
<TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDiagram")}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "pink",
color: "#000",
}}
>
AI Beta
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
</TTDDialogTabTriggers>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best
results if you describe a diagram, workflow, flow chart, and
similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderBottomRight={() => {
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color:
ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</TTDDialogTab>
)}
</TTDDialogTabs>
</Dialog>
);
},
);

View File

@ -0,0 +1,52 @@
import { ChangeEventHandler, useEffect, useRef } from "react";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onKeyboardSubmit?: () => void;
}
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
useEffect(() => {
if (!callbackRef.current) {
return;
}
const textarea = ref.current;
if (textarea) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
event.preventDefault();
callbackRef.current?.();
}
};
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
autoFocus
ref={ref}
/>
);
};

View File

@ -0,0 +1,39 @@
import Spinner from "../Spinner";
const ErrorComp = ({ error }: { error: string }) => {
return (
<div
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement>;
loaded: boolean;
}
export const TTDDialogOutput = ({
error,
canvasRef,
loaded,
}: TTDDialogOutputProps) => {
return (
<div className="ttd-dialog-output-wrapper">
{error && <ErrorComp error={error.message} />}
{loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="ttd-dialog-output-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
);
};

View File

@ -0,0 +1,58 @@
import { ReactNode } from "react";
import { Button } from "../Button";
import clsx from "clsx";
import Spinner from "../Spinner";
interface TTDDialogPanelProps {
label: string;
children: ReactNode;
panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode;
renderBottomRight?: () => ReactNode;
}
export const TTDDialogPanel = ({
label,
children,
panelAction,
panelActionDisabled = false,
onTextSubmitInProgess,
renderTopRight,
renderBottomRight,
}: TTDDialogPanelProps) => {
return (
<div className="ttd-dialog-panel">
<div className="ttd-dialog-panel__header">
<label>{label}</label>
{renderTopRight?.()}
</div>
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{renderBottomRight?.()}
</div>
</div>
);
};

View File

@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};

View File

@ -0,0 +1,17 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTab = ({
tab,
children,
...rest
}: {
tab: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
TTDDialogTab.displayName = "TTDDialogTab";

View File

@ -0,0 +1,21 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: string;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
{children}
</button>
</RadixTabs.Trigger>
);
};
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";

View File

@ -0,0 +1,13 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";

View File

@ -0,0 +1,64 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { ReactNode, useRef } from "react";
import { useExcalidrawSetAppState } from "../App";
import { isMemberOf } from "../../utils";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & (
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
),
) => {
const setAppState = useExcalidrawSetAppState();
const rootRef = useRef<HTMLDivElement>(null);
const minHeightRef = useRef<number>(0);
return (
<RadixTabs.Root
ref={rootRef}
className="ttd-dialog-tabs-root"
value={props.tab}
onValueChange={(
// at least in test enviros, `tab` can be `undefined`
tab: string | undefined,
) => {
if (!tab) {
return;
}
const modalContentNode =
rootRef.current?.closest<HTMLElement>(".Modal__content");
if (modalContentNode) {
const currHeight = modalContentNode.offsetHeight || 0;
if (currHeight > minHeightRef.current) {
minHeightRef.current = currHeight;
modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
}
}
if (
props.dialog === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab },
});
}
}}
>
{props.children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;

View File

@ -0,0 +1,34 @@
import { ReactNode } from "react";
import { useTunnels } from "../../context/tunnels";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { useExcalidrawSetAppState } from "../App";
import { brainIcon } from "../icons";
import { t } from "../../i18n";
import { trackEvent } from "../../analytics";
export const TTDDialogTrigger = ({
children,
icon,
}: {
children?: ReactNode;
icon?: JSX.Element;
}) => {
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
};
TTDDialogTrigger.displayName = "TTDDialogTrigger";

View File

@ -0,0 +1,164 @@
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppClassProperties, BinaryFiles } from "../../types";
import { canvasToBlob } from "../../data/blob";
const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement>;
setError: (error: Error | null) => void;
}) => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
mermaidDefinition: string;
setError: (error: Error | null) => void;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
}
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
mermaidDefinition,
setError,
data,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return;
}
if (!mermaidDefinition) {
resetPreview({ canvasRef, setError });
return;
}
try {
const api = await mermaidToExcalidrawLib.api;
let ret;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
const { elements, files } = ret;
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (err: any) {
console.error(err);
parent.style.background = "var(--default-bg-color)";
if (mermaidDefinition) {
setError(err);
}
throw err;
}
};
export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
export const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const insertToEditor = ({
app,
data,
text,
shouldSaveMermaidDataToStorage,
}: {
app: AppClassProperties;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data.current;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};

View File

@ -4,12 +4,15 @@ import {
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
useState,
} from "react";
import clsx from "clsx";
import "./TextField.scss";
import { Button } from "./Button";
import { eyeIcon, eyeClosedIcon } from "./icons";
export type TextFieldProps = {
type TextFieldProps = {
value?: string;
onChange?: (value: string) => void;
@ -22,6 +25,7 @@ export type TextFieldProps = {
label?: string;
placeholder?: string;
isRedacted?: boolean;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly,
selectOnRender,
onKeyDown,
isRedacted = false,
},
ref,
) => {
@ -48,6 +53,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
}
}, [selectOnRender]);
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
useState<boolean>(false);
return (
<div
className={clsx("ExcTextField", {
@ -64,14 +72,26 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})}
>
<input
className={clsx({
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
})}
readOnly={readonly}
type="text"
value={value}
placeholder={placeholder}
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
{isRedacted && (
<Button
onSelect={() =>
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
}
style={{ border: 0, userSelect: "none" }}
>
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
</Button>
)}
</div>
</div>
);

View File

@ -175,7 +175,8 @@
}
}
.ToolIcon__LaserPointer .ToolIcon__icon {
.ToolIcon__LaserPointer .ToolIcon__icon,
.ToolIcon__MagicButton .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}

View File

@ -189,8 +189,6 @@ const getRelevantAppStateProps = (
suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
openSidebar: appState.openSidebar,
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,

View File

@ -63,9 +63,13 @@
}
&__text {
display: flex;
align-items: center;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
gap: 0.75rem;
}
&__shortcut {

View File

@ -37,6 +37,32 @@ const DropdownMenuItem = ({
</button>
);
};
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadge = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "1px 4px",
background: "pink",
borderRadius: 6,
fontSize: 11,
color: "black",
fontFamily: "monospace",
}}
>
{children}
</div>
);
};
DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
DropdownMenuItem.Badge = DropDownMenuItemBadge;
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";

View File

@ -1688,3 +1688,70 @@ export const laserPointerToolIcon = createIcon(
20,
);
export const MagicIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
<path d="M15 6l3 3" />
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
</g>,
tablerIconProps,
);
export const OpenAIIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11.217 19.384a3.501 3.501 0 0 0 6.783 -1.217v-5.167l-6 -3.35" />
<path d="M5.214 15.014a3.501 3.501 0 0 0 4.446 5.266l4.34 -2.534v-6.946" />
<path d="M6 7.63c-1.391 -.236 -2.787 .395 -3.534 1.689a3.474 3.474 0 0 0 1.271 4.745l4.263 2.514l6 -3.348" />
<path d="M12.783 4.616a3.501 3.501 0 0 0 -6.783 1.217v5.067l6 3.45" />
<path d="M18.786 8.986a3.501 3.501 0 0 0 -4.446 -5.266l-4.34 2.534v6.946" />
<path d="M18 16.302c1.391 .236 2.787 -.395 3.534 -1.689a3.474 3.474 0 0 0 -1.271 -4.745l-4.308 -2.514l-5.955 3.42" />
</g>,
tablerIconProps,
);
export const fullscreenIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</g>,
tablerIconProps,
);
export const eyeIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</g>,
tablerIconProps,
);
export const eyeClosedIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
<path d="M3 3l18 18" />
</g>,
tablerIconProps,
);
export const brainIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
</g>,
tablerIconProps,
);

View File

@ -107,7 +107,7 @@ export const SaveAsImage = () => {
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
@ -230,7 +230,7 @@ export const Export = () => {
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
setAppState({ openDialog: { name: "jsonExport" } });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}

View File

@ -80,6 +80,7 @@ export enum EVENT {
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
}
export const YOUTUBE_STATES = {
@ -344,4 +345,33 @@ export const DEFAULT_SIDEBAR = {
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);
export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",
"image",
] as const);
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
image: "image",
eraser: "eraser",
hand: "hand",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {
OAI_API_KEY: "excalidraw-oai-api-key",
// legacy naming (non)scheme
PUBLISH_LIBRARY: "publish-library-data",
} as const;

View File

@ -13,6 +13,7 @@ type TunnelsContextValue = {
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol;
};
@ -32,6 +33,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);

View File

@ -5,9 +5,11 @@
--zIndex-canvas: 1;
--zIndex-interactiveCanvas: 2;
--zIndex-wysiwyg: 3;
--zIndex-canvasButtons: 3;
--zIndex-layerUI: 4;
--zIndex-eyeDropperBackdrop: 5;
--zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7;
--zIndex-modal: 1000;
--zIndex-popup: 1001;
@ -37,6 +39,7 @@
button {
cursor: pointer;
user-select: none;
}
&:focus {
@ -531,6 +534,12 @@
}
}
input.is-redacted {
// we don't use type=password because browsers (chrome?) prompt
// you to save it which is annoying
-webkit-text-security: disc;
}
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
@ -643,6 +652,19 @@
--button-bg: var(--color-surface-high);
}
}
.excalidraw__paragraph {
margin: 1rem 0;
}
.Modal__content {
.excalidraw__paragraph:first-child {
margin-top: 0;
}
.excalidraw__paragraph + .excalidraw__paragraph {
margin-top: 0rem;
}
}
}
.ErrorSplash.excalidraw {

View File

@ -0,0 +1,51 @@
import { EDITOR_LS_KEYS } from "../constants";
import { JSONValue } from "../types";
export class EditorLocalStorage {
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
try {
return !!window.localStorage.getItem(key);
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return false;
}
}
static get<T extends JSONValue>(
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) {
try {
const value = window.localStorage.getItem(key);
if (value) {
return JSON.parse(value) as T;
}
return null;
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return null;
}
}
static set = (
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
value: JSONValue,
) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error: any) {
console.warn(`localStorage.setItem error: ${error.message}`);
return false;
}
};
static delete = (
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) => {
try {
window.localStorage.removeItem(name);
} catch (error: any) {
console.warn(`localStorage.removeItem error: ${error.message}`);
}
};
}

300
src/data/ai/types.ts Normal file
View File

@ -0,0 +1,300 @@
export namespace OpenAIInput {
type ChatCompletionContentPart =
| ChatCompletionContentPartText
| ChatCompletionContentPartImage;
interface ChatCompletionContentPartImage {
image_url: ChatCompletionContentPartImage.ImageURL;
/**
* The type of the content part.
*/
type: "image_url";
}
namespace ChatCompletionContentPartImage {
export interface ImageURL {
/**
* Either a URL of the image or the base64 encoded image data.
*/
url: string;
/**
* Specifies the detail level of the image.
*/
detail?: "auto" | "low" | "high";
}
}
interface ChatCompletionContentPartText {
/**
* The text content.
*/
text: string;
/**
* The type of the content part.
*/
type: "text";
}
interface ChatCompletionUserMessageParam {
/**
* The contents of the user message.
*/
content: string | Array<ChatCompletionContentPart> | null;
/**
* The role of the messages author, in this case `user`.
*/
role: "user";
}
interface ChatCompletionSystemMessageParam {
/**
* The contents of the system message.
*/
content: string | null;
/**
* The role of the messages author, in this case `system`.
*/
role: "system";
}
export interface ChatCompletionCreateParamsBase {
/**
* A list of messages comprising the conversation so far.
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
*/
messages: Array<
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
>;
/**
* ID of the model to use. See the
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
* table for details on which models work with the Chat API.
*/
model:
| (string & {})
| "gpt-4-1106-preview"
| "gpt-4-vision-preview"
| "gpt-4"
| "gpt-4-0314"
| "gpt-4-0613"
| "gpt-4-32k"
| "gpt-4-32k-0314"
| "gpt-4-32k-0613"
| "gpt-3.5-turbo"
| "gpt-3.5-turbo-16k"
| "gpt-3.5-turbo-0301"
| "gpt-3.5-turbo-0613"
| "gpt-3.5-turbo-16k-0613";
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
* existing frequency in the text so far, decreasing the model's likelihood to
* repeat the same line verbatim.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
frequency_penalty?: number | null;
/**
* Modify the likelihood of specified tokens appearing in the completion.
*
* Accepts a JSON object that maps tokens (specified by their token ID in the
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
* bias is added to the logits generated by the model prior to sampling. The exact
* effect will vary per model, but values between -1 and 1 should decrease or
* increase likelihood of selection; values like -100 or 100 should result in a ban
* or exclusive selection of the relevant token.
*/
logit_bias?: Record<string, number> | null;
/**
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
*
* The total length of input tokens and generated tokens is limited by the model's
* context length.
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
* for counting tokens.
*/
max_tokens?: number | null;
/**
* How many chat completion choices to generate for each input message.
*/
n?: number | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
* whether they appear in the text so far, increasing the model's likelihood to
* talk about new topics.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
presence_penalty?: number | null;
/**
* This feature is in Beta. If specified, our system will make a best effort to
* sample deterministically, such that repeated requests with the same `seed` and
* parameters should return the same result. Determinism is not guaranteed, and you
* should refer to the `system_fingerprint` response parameter to monitor changes
* in the backend.
*/
seed?: number | null;
/**
* Up to 4 sequences where the API will stop generating further tokens.
*/
stop?: string | null | Array<string>;
/**
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
* sent as data-only
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
* as they become available, with the stream terminated by a `data: [DONE]`
* message.
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
*/
stream?: boolean | null;
/**
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
* make the output more random, while lower values like 0.2 will make it more
* focused and deterministic.
*
* We generally recommend altering this or `top_p` but not both.
*/
temperature?: number | null;
/**
* An alternative to sampling with temperature, called nucleus sampling, where the
* model considers the results of the tokens with top_p probability mass. So 0.1
* means only the tokens comprising the top 10% probability mass are considered.
*
* We generally recommend altering this or `temperature` but not both.
*/
top_p?: number | null;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
*/
user?: string;
}
}
export namespace OpenAIOutput {
export interface ChatCompletion {
/**
* A unique identifier for the chat completion.
*/
id: string;
/**
* A list of chat completion choices. Can be more than one if `n` is greater
* than 1.
*/
choices: Array<Choice>;
/**
* The Unix timestamp (in seconds) of when the chat completion was created.
*/
created: number;
/**
* The model used for the chat completion.
*/
model: string;
/**
* The object type, which is always `chat.completion`.
*/
object: "chat.completion";
/**
* This fingerprint represents the backend configuration that the model runs with.
*
* Can be used in conjunction with the `seed` request parameter to understand when
* backend changes have been made that might impact determinism.
*/
system_fingerprint?: string;
/**
* Usage statistics for the completion request.
*/
usage?: CompletionUsage;
}
export interface Choice {
/**
* The reason the model stopped generating tokens. This will be `stop` if the model
* hit a natural stop point or a provided stop sequence, `length` if the maximum
* number of tokens specified in the request was reached, `content_filter` if
* content was omitted due to a flag from our content filters, `tool_calls` if the
* model called a tool, or `function_call` (deprecated) if the model called a
* function.
*/
finish_reason:
| "stop"
| "length"
| "tool_calls"
| "content_filter"
| "function_call";
/**
* The index of the choice in the list of choices.
*/
index: number;
/**
* A chat completion message generated by the model.
*/
message: ChatCompletionMessage;
}
interface ChatCompletionMessage {
/**
* The contents of the message.
*/
content: string | null;
/**
* The role of the author of this message.
*/
role: "assistant";
}
/**
* Usage statistics for the completion request.
*/
interface CompletionUsage {
/**
* Number of tokens in the generated completion.
*/
completion_tokens: number;
/**
* Number of tokens in the prompt.
*/
prompt_tokens: number;
/**
* Total number of tokens used in the request (prompt + completion).
*/
total_tokens: number;
}
export interface APIError {
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
readonly headers: Headers | undefined;
readonly error: { message: string } | undefined;
readonly code: string | null | undefined;
readonly param: string | null | undefined;
readonly type: string | undefined;
}
}

View File

@ -3,10 +3,11 @@ import {
copyTextToSystemClipboard,
} from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements, isFrameElement } from "../element";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { t } from "../i18n";
@ -38,7 +39,7 @@ export const prepareElementsForExport = (
exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameElement | null = null;
let exportingFrame: ExcalidrawFrameLikeElement | null = null;
let exportedElements = isExportingSelection
? getSelectedElements(
elements,
@ -50,7 +51,10 @@ export const prepareElementsForExport = (
: elements;
if (isExportingSelection) {
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) {
if (
exportedElements.length === 1 &&
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({
elements,
@ -93,7 +97,7 @@ export const exportCanvas = async (
viewBackgroundColor: string;
name: string;
fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameElement | null;
exportingFrame: ExcalidrawFrameLikeElement | null;
},
) => {
if (elements.length === 0) {

104
src/data/magic.ts Normal file
View File

@ -0,0 +1,104 @@
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = "light",
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}

View File

@ -1,5 +1,6 @@
import {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
embeddable: true,
hand: true,
laser: false,
magicframe: false,
};
export type RestoredDataState = {
@ -112,7 +114,7 @@ const restoreElementWithProperties = <
// @ts-ignore TS complains here but type checks the call sites fine.
keyof K
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
@ -163,8 +165,9 @@ const restoreElementWithProperties = <
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
if ("customData" in element || "customData" in extra) {
base.customData =
"customData" in extra ? extra.customData : element.customData;
}
if (PRECEDING_ELEMENT_KEY in element) {
@ -273,7 +276,7 @@ const restoreElement = (
return restoreElementWithProperties(element, {
type:
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
@ -289,15 +292,15 @@ const restoreElement = (
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
case "rectangle":
return restoreElementWithProperties(element, {});
case "diamond":
case "iframe":
return restoreElementWithProperties(element, {});
case "embeddable":
return restoreElementWithProperties(element, {
validated: null,
});
case "magicframe":
case "frame":
return restoreElementWithProperties(element, {
name: element.name ?? null,

View File

@ -15,6 +15,7 @@ import {
ElementConstructorOpts,
newFrameElement,
newImageElement,
newMagicFrameElement,
newTextElement,
} from "../element/newElement";
import {
@ -26,12 +27,13 @@ import {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FileId,
@ -61,7 +63,12 @@ export type ValidLinearElement = {
| {
type: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
@ -69,7 +76,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
@ -93,7 +105,12 @@ export type ValidLinearElement = {
| {
type: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
@ -101,7 +118,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable"
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
@ -137,7 +159,7 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
@ -163,7 +185,12 @@ export type ExcalidrawElementSkeleton =
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>);
} & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
@ -547,7 +574,16 @@ export const convertToExcalidrawElements = (
});
break;
}
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw":
case "iframe":
case "embeddable": {
excalidrawElement = element;
break;
@ -656,7 +692,7 @@ export const convertToExcalidrawElements = (
// need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") {
if (element.type !== "frame" && element.type !== "magicframe") {
continue;
}
const frame = elementStore.getElement(id);

View File

@ -0,0 +1,14 @@
.excalidraw {
.excalidraw-canvas-buttons {
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: var(--zIndex-canvasButtons);
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
}

View File

@ -0,0 +1,60 @@
import { AppState } from "../types";
import { sceneCoordsToViewportCoords } from "../utils";
import { NonDeletedExcalidrawElement } from "./types";
import { getElementAbsoluteCoords } from ".";
import { useExcalidrawAppState } from "../components/App";
import "./ElementCanvasButtons.scss";
const CONTAINER_PADDING = 5;
const getContainerCoords = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
const [x1, y1] = getElementAbsoluteCoords(element);
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x1 + element.width, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft + 10;
const y = viewportY - appState.offsetTop;
return { x, y };
};
export const ElementCanvasButtons = ({
children,
element,
}: {
children: React.ReactNode;
element: NonDeletedExcalidrawElement;
}) => {
const appState = useExcalidrawAppState();
if (
appState.contextMenu ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
appState.viewModeEnabled
) {
return null;
}
const { x, y } = getContainerCoords(element, appState);
return (
<div
className="excalidraw-canvas-buttons"
style={{
top: `${y}px`,
left: `${x}px`,
// width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{children}
</div>
);
};

View File

@ -6,7 +6,7 @@
justify-content: space-between;
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: 100;
z-index: var(--zIndex-hyperlinkContainer);
background: var(--island-bg-color);
border-radius: var(--border-radius-md);
box-sizing: border-box;

View File

@ -121,7 +121,7 @@ export const Hyperlink = ({
setToast({ message: embedLink.warning, closable: true });
}
const ar = embedLink
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
: 1;
const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link;
@ -210,6 +210,7 @@ export const Hyperlink = ({
};
const { x, y } = getCoordsForPopover(element, appState);
if (
appState.contextMenu ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||

View File

@ -13,6 +13,7 @@ import { Point } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
@ -22,6 +23,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
export type RectangleBox = {
x: number;
@ -53,16 +55,29 @@ export class ElementBounds {
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) {
if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
// hack to ensure that downstream checks could retrieve element Scene
// so as to have correctly calculated bounds
// FIXME remove when we get rid of all the id:Scene / element:Scene mapping
const shouldCache = Scene.getScene(element);
if (shouldCache) {
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
}
return bounds;
}

View File

@ -18,7 +18,6 @@ import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawRectangleElement,
ExcalidrawEmbeddableElement,
ExcalidrawDiamondElement,
ExcalidrawTextElement,
ExcalidrawEllipseElement,
@ -27,7 +26,8 @@ import {
ExcalidrawImageElement,
ExcalidrawLinearElement,
StrokeRoundness,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
} from "./types";
import {
@ -41,7 +41,8 @@ import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import {
hasBoundTextElement,
isEmbeddableElement,
isFrameLikeElement,
isIframeLikeElement,
isImageElement,
} from "./typeChecks";
import { isTextElement } from ".";
@ -64,7 +65,7 @@ const isElementDraggableFromInside = (
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) ||
isEmbeddableElement(element);
isIframeLikeElement(element);
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
@ -186,7 +187,7 @@ export const isPointHittingElementBoundingBox = (
// by its frame, whether it has been selected or not
// this logic here is not ideal
// TODO: refactor it later...
if (element.type === "frame") {
if (isFrameLikeElement(element)) {
return hitTestPointAgainstElement({
element,
point: [x, y],
@ -255,6 +256,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "image":
case "text":
@ -282,7 +284,8 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.",
);
return false;
case "frame": {
case "frame":
case "magicframe": {
// check distance to frame element first
if (
args.check(
@ -314,8 +317,10 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@ -346,8 +351,8 @@ const distanceToRectangle = (
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -662,8 +667,10 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
ret = c / (hwidth * (nabs + q * mabs));
break;
case "diamond":
@ -700,8 +707,10 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@ -752,8 +761,10 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@ -788,8 +799,8 @@ const getCorners = (
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@ -798,8 +809,10 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@ -948,8 +961,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement,
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,

View File

@ -11,7 +11,7 @@ import Scene from "../scene/Scene";
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
isFrameLikeElement,
} from "./typeChecks";
export const dragSelectedElements = (
@ -33,7 +33,7 @@ export const dragSelectedElements = (
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameElement(e))
.filter((e) => isFrameLikeElement(e))
.map((f) => f.id);
if (frames.length > 0) {

View File

@ -6,25 +6,19 @@ import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks";
import {
isFrameLikeElement,
isIframeElement,
isIframeLikeElement,
} from "./typeChecks";
import {
ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawIframeLikeElement,
IframeData,
NonDeletedExcalidrawElement,
Theme,
} from "./types";
type EmbeddedLink =
| ({
aspectRatio: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
))
| null;
const embeddedLinkCache = new Map<string, EmbeddedLink>();
const embeddedLinkCache = new Map<string, IframeData>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
@ -67,11 +61,13 @@ const ALLOWED_DOMAINS = new Set([
"dddice.com",
]);
const createSrcDoc = (body: string) => {
export const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`;
};
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
export const getEmbedLink = (
link: string | null | undefined,
): IframeData | null => {
if (!link) {
return null;
}
@ -104,8 +100,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
break;
}
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
}
const vimeoLink = link.match(RE_VIMEO);
@ -119,8 +119,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type, warning };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type, warning };
}
const figmaLink = link.match(RE_FIGMA);
@ -130,27 +134,35 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
link,
)}`;
aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
}
const valLink = link.match(RE_VALTOWN);
if (valLink) {
link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
}
if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink;
let ret: IframeData;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 },
intrinsicSize: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
@ -160,7 +172,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
intrinsicSize: { w: 480, h: 480 },
};
}
embeddedLinkCache.set(originalLink, ret);
@ -168,14 +180,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
}
if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink;
let ret: IframeData;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 },
intrinsicSize: { w: 550, h: 720 },
};
// assume regular url
} else {
@ -190,26 +202,26 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
aspectRatio: { w: 550, h: 720 },
intrinsicSize: { w: 550, h: 720 },
};
}
embeddedLinkCache.set(link, ret);
return ret;
}
embeddedLinkCache.set(link, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
return { link, intrinsicSize: aspectRatio, type };
};
export const isEmbeddableOrLabel = (
export const isIframeLikeOrItsLabel = (
element: NonDeletedExcalidrawElement,
): Boolean => {
if (isEmbeddableElement(element)) {
if (isIframeLikeElement(element)) {
return true;
}
if (element.type === "text") {
const container = getContainerElement(element);
if (container && isEmbeddableElement(container)) {
if (container && isFrameLikeElement(container)) {
return true;
}
}
@ -217,10 +229,16 @@ export const isEmbeddableOrLabel = (
};
export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawEmbeddableElement,
element: ExcalidrawIframeLikeElement,
): ExcalidrawElement => {
const text =
let text: string;
if (isIframeElement(element)) {
text = "IFrame element";
} else {
text =
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
}
const fontSize = Math.max(
Math.min(element.width / 2, element.width / text.length),
element.width / 30,

View File

@ -2,7 +2,6 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameElement,
} from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
@ -50,11 +49,7 @@ export {
getDragOffsetXY,
dragNewElement,
} from "./dragElements";
export {
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { isTextElement, isExcalidrawElement } from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement";
export {
@ -74,17 +69,10 @@ export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter(
(element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
frames.filter(
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,

View File

@ -14,6 +14,8 @@ import {
ExcalidrawTextContainer,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawIframeElement,
} from "../element/types";
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@ -169,6 +171,16 @@ export const newEmbeddableElement = (
};
};
export const newIframeElement = (
opts: {
type: "iframe";
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawIframeElement> => {
return {
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
};
};
export const newFrameElement = (
opts: {
name?: string;
@ -186,6 +198,23 @@ export const newFrameElement = (
return frameElement;
};
export const newMagicFrameElement = (
opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawMagicFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
type: "magicframe",
name: opts?.name || null,
},
{},
);
return frameElement;
};
/** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = (
opts: {

View File

@ -27,7 +27,7 @@ import {
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
isFrameLikeElement,
isFreeDrawElement,
isImageElement,
isLinearElement,
@ -163,7 +163,7 @@ const rotateSingleElement = (
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: number;
if (isFrameElement(element)) {
if (isFrameLikeElement(element)) {
angle = 0;
} else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
@ -896,7 +896,7 @@ const rotateMultipleElements = (
}
elements
.filter((element) => element.type !== "frame")
.filter((element) => !isFrameLikeElement(element))
.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;

View File

@ -2,7 +2,7 @@ import { useEffect } from "react";
import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
import { getNonDeletedElements } from "../";
import { getSelectedElements } from "../../scene";
import { AppState, ExcalidrawImperativeAPI } from "../../types";
import { AppState, ExcalidrawImperativeAPI, ToolType } from "../../types";
import { registerAuxLangData } from "../../i18n";
import {
@ -44,7 +44,7 @@ let alwaysEnabledMap: readonly {
export type SubtypeRecord = Readonly<{
subtype: Subtype;
parents: readonly ExcalidrawElement["type"][];
parents: readonly (ExcalidrawElement["type"] & ToolType)[];
actionNames?: readonly SubtypeActionName[];
disabledNames?: readonly DisabledActionName[];
shortcutMap?: Record<string, string[]>;

View File

@ -2,6 +2,7 @@ import { getSubtypeMethods, SubtypeMethods } from "./subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
@ -886,7 +887,7 @@ const VALID_CONTAINER_TYPES = new Set([
]);
export const isValidTextContainer = (element: {
type: ExcalidrawElement["type"];
type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (

View File

@ -8,7 +8,7 @@ import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameElement, isLinearElement } from "./typeChecks";
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection =
@ -257,7 +257,7 @@ export const getTransformHandles = (
}
} else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) {
} else if (isFrameLikeElement(element)) {
omitSides = {
rotation: true,
};

View File

@ -1,5 +1,5 @@
import { ROUNDNESS } from "../constants";
import { AppState } from "../types";
import { ElementOrToolType } from "../types";
import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import {
@ -8,7 +8,6 @@ import {
ExcalidrawEmbeddableElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
@ -16,21 +15,13 @@ import {
ExcalidrawTextContainer,
ExcalidrawFrameElement,
RoundnessType,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
} from "./types";
export const isGenericElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawGenericElement => {
return (
element != null &&
(element.type === "selection" ||
element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "embeddable")
);
};
export const isInitializedImageElement = (
element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => {
@ -49,6 +40,20 @@ export const isEmbeddableElement = (
return !!element && element.type === "embeddable";
};
export const isIframeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeElement => {
return !!element && element.type === "iframe";
};
export const isIframeLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeLikeElement => {
return (
!!element && (element.type === "iframe" || element.type === "embeddable")
);
};
export const isTextElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => {
@ -61,6 +66,21 @@ export const isFrameElement = (
return element != null && element.type === "frame";
};
export const isMagicFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawMagicFrameElement => {
return element != null && element.type === "magicframe";
};
export const isFrameLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameLikeElement => {
return (
element != null &&
(element.type === "frame" || element.type === "magicframe")
);
};
export const isFreeDrawElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => {
@ -68,7 +88,7 @@ export const isFreeDrawElement = (
};
export const isFreeDrawElementType = (
elementType: ExcalidrawElement["type"],
elementType: ExcalidrawElementType,
): boolean => {
return elementType === "freedraw";
};
@ -86,7 +106,7 @@ export const isArrowElement = (
};
export const isLinearElementType = (
elementType: AppState["activeTool"]["type"],
elementType: ElementOrToolType,
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@ -105,7 +125,7 @@ export const isBindingElement = (
};
export const isBindingElementType = (
elementType: AppState["activeTool"]["type"],
elementType: ElementOrToolType,
): boolean => {
return elementType === "arrow";
};
@ -121,8 +141,10 @@ export const isBindableElement = (
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
@ -144,7 +166,7 @@ export const isTextBindableContainer = (
export const isExcalidrawElement = (
element: any,
): element is ExcalidrawElement => {
const type: ExcalidrawElement["type"] | undefined = element?.type;
const type: ExcalidrawElementType | undefined = element?.type;
if (!type) {
return false;
}
@ -152,12 +174,14 @@ export const isExcalidrawElement = (
case "text":
case "diamond":
case "rectangle":
case "iframe":
case "embeddable":
case "ellipse":
case "arrow":
case "freedraw":
case "line":
case "frame":
case "magicframe":
case "image":
case "selection": {
return true;
@ -190,7 +214,7 @@ export const isBoundToContainer = (
};
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "embeddable";
type === "rectangle" || type === "embeddable" || type === "iframe";
export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond";

View File

@ -7,6 +7,7 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types";
import { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@ -98,6 +99,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
validated: boolean | null;
}>;
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{
type: "iframe";
// TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData };
}>;
export type ExcalidrawIframeLikeElement =
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type IframeData =
| {
intrinsicSize: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
);
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
@ -118,6 +139,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
name: string | null;
};
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
type: "magicframe";
name: string | null;
};
export type ExcalidrawFrameLikeElement =
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
/**
* These are elements that don't have any additional properties.
*/
@ -139,6 +169,8 @@ export type ExcalidrawElement =
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
@ -171,8 +203,10 @@ export type ExcalidrawBindableElement =
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement;
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
@ -218,3 +252,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
}>;
export type FileId = string & { _brand: "FileId" };
export type ExcalidrawElementType = ExcalidrawElement["type"];

View File

@ -5,7 +5,7 @@ import {
} from "./element";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
@ -18,11 +18,11 @@ import { arrayToMap } from "./utils";
import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
@ -58,7 +58,7 @@ export const bindElementsToFramesAfterDuplication = (
export function isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) {
const frameLineSegments = getElementLineSegments(frame);
@ -75,20 +75,20 @@ export function isElementIntersectingFrame(
export const getElementsCompletelyInFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) =>
omitGroupsContainingFrames(
omitGroupsContainingFrameLikes(
getElementsWithinSelection(elements, frame, false),
).filter(
(element) =>
(element.type !== "frame" && !element.frameId) ||
(!isFrameLikeElement(element) && !element.frameId) ||
element.frameId === frame.id,
);
export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id,
@ -97,12 +97,12 @@ export const isElementContainingFrame = (
export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame);
@ -120,7 +120,7 @@ export const elementsAreInFrameBounds = (
export const elementOverlapsWithFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
return (
elementsAreInFrameBounds([element], frame) ||
@ -134,7 +134,7 @@ export const isCursorInFrame = (
x: number;
y: number;
},
frame: NonDeleted<ExcalidrawFrameElement>,
frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
@ -148,7 +148,7 @@ export const isCursorInFrame = (
export const groupsAreAtLeastIntersectingTheFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
@ -168,7 +168,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
export const groupsAreCompletelyOutOfFrame = (
elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId),
@ -192,14 +192,14 @@ export const groupsAreCompletelyOutOfFrame = (
/**
* Returns a map of frameId to frame elements. Includes empty frames.
*/
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
const frameElementsMap = new Map<
ExcalidrawElement["id"],
ExcalidrawElement[]
>();
for (const element of elements) {
const frameId = isFrameElement(element) ? element.id : element.frameId;
const frameId = isFrameLikeElement(element) ? element.id : element.frameId;
if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
}
@ -213,12 +213,12 @@ export const getFrameChildren = (
frameId: string,
) => allElements.filter((element) => element.frameId === frameId);
export const getFrameElements = (
export const getFrameLikeElements = (
allElements: ExcalidrawElementsIncludingDeleted,
): ExcalidrawFrameElement[] => {
return allElements.filter((element) =>
isFrameElement(element),
) as ExcalidrawFrameElement[];
): ExcalidrawFrameLikeElement[] => {
return allElements.filter((element): element is ExcalidrawFrameLikeElement =>
isFrameLikeElement(element),
);
};
/**
@ -232,7 +232,7 @@ export const getFrameElements = (
export const getRootElements = (
allElements: ExcalidrawElementsIncludingDeleted,
) => {
const frameElements = arrayToMap(getFrameElements(allElements));
const frameElements = arrayToMap(getFrameLikeElements(allElements));
return allElements.filter(
(element) =>
frameElements.has(element.id) ||
@ -243,7 +243,7 @@ export const getRootElements = (
export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameChildren(allElements, frame.id);
@ -336,9 +336,9 @@ export const getElementsInResizingFrame = (
export const getElementsInNewFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
return omitGroupsContainingFrames(
return omitGroupsContainingFrameLikes(
allElements,
getElementsCompletelyInFrame(allElements, frame),
);
@ -356,12 +356,12 @@ export const getContainingFrame = (
if (element.frameId) {
if (elementsMap) {
return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameElement;
null) as null | ExcalidrawFrameLikeElement;
}
return (
(Scene.getScene(element)?.getElement(
element.frameId,
) as ExcalidrawFrameElement) || null
) as ExcalidrawFrameLikeElement) || null
);
}
return null;
@ -377,7 +377,7 @@ export const getContainingFrame = (
export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
) => {
const { currTargetFrameChildrenMap } = allElements.reduce(
(acc, element, index) => {
@ -397,7 +397,7 @@ export const addElementsToFrame = (
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrames(
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
)) {
@ -438,7 +438,7 @@ export const removeElementsFromFrame = (
>();
const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"],
ExcalidrawFrameLikeElement["id"],
ExcalidrawElement[]
>();
@ -474,7 +474,7 @@ export const removeElementsFromFrame = (
export const removeAllElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
appState: AppState,
) => {
const elementsInFrame = getFrameChildren(allElements, frame.id);
@ -484,7 +484,7 @@ export const removeAllElementsFromFrame = (
export const replaceAllElementsInFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
appState: AppState,
) => {
return addElementsToFrame(
@ -524,7 +524,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => {
if (
element.frameId &&
!isFrameElement(element) &&
!isFrameLikeElement(element) &&
!isElementInFrame(element, allElements, appState)
) {
elementsToRemove.add(element);
@ -540,7 +540,7 @@ export const updateFrameMembershipOfSelectedElements = (
* filters out elements that are inside groups that contain a frame element
* anywhere in the group tree
*/
export const omitGroupsContainingFrames = (
export const omitGroupsContainingFrameLikes = (
allElements: ExcalidrawElementsIncludingDeleted,
/** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily
@ -558,7 +558,9 @@ export const omitGroupsContainingFrames = (
const rejectedGroupIds = new Set<string>();
for (const groupId of uniqueGroupIds) {
if (
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
getElementsInGroup(allElements, groupId).some((el) =>
isFrameLikeElement(el),
)
) {
rejectedGroupIds.add(groupId);
}
@ -636,7 +638,7 @@ export const isElementInFrame = (
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) {
if (isFrameLikeElement(elementInGroup)) {
return false;
}
}
@ -650,3 +652,15 @@ export const isElementInFrame = (
return false;
};
export const getFrameLikeTitle = (
element: ExcalidrawFrameLikeElement,
frameIdx: number,
) => {
const existingName = element.name?.trim();
if (existingName) {
return existingName;
}
// TODO name frames AI only is specific to AI frames
return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`;
};

View File

@ -11,6 +11,8 @@
"copyAsPng": "Copy to clipboard as PNG",
"copyAsSvg": "Copy to clipboard as SVG",
"copyText": "Copy to clipboard as text",
"copySource": "Copy source to clipboard",
"convertToCode": "Convert to code",
"bringForward": "Bring forward",
"sendToBack": "Send to back",
"bringToFront": "Bring to front",
@ -130,7 +132,9 @@
"sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas"
"eyeDropper": "Pick color from canvas",
"textToDiagram": "Text to diagram",
"prompt": "Prompt"
},
"library": {
"noItems": "No items added yet...",
@ -218,6 +222,7 @@
},
"libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.",
"iframe": "IFrame elements cannot be added to the library.",
"image": "Support for adding images to the library coming soon!"
},
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
@ -240,11 +245,13 @@
"link": "Add/ Update link for a selected shape",
"eraser": "Eraser",
"frame": "Frame tool",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw"
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
},
"headings": {
"canvasActions": "Canvas actions",

View File

@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
## Unreleased
### Breaking Changes
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.0 (2023-11-14)
### Features

View File

@ -76,6 +76,8 @@ const {
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
} = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32;
@ -681,7 +683,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => {
console.info("Elements :", elements, "State : ", state);
// console.info("Elements :", elements, "State : ", state);
}}
onPointerUpdate={(payload: {
pointer: { x: number; y: number };
@ -737,6 +739,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</Excalidraw>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()}

View File

@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
children,
validateEmbeddable,
renderEmbeddable,
aiEnabled,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@ -122,6 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onScrollChange={onScrollChange}
validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
>
{children}
</App>
@ -244,6 +246,8 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar";
export { TTDDialog } from "../../components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "../../components/TTDDialog/TTDDialogTrigger";
export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform";

View File

@ -6,7 +6,7 @@ import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
NonDeleted,
} from "../element/types";
import { restore } from "../data/restore";
@ -26,7 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null;
maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null;
exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: (
width: number,
height: number,

View File

@ -13,6 +13,7 @@ import {
isInitializedImageElement,
isArrowElement,
hasBoundTextElement,
isMagicFrameElement,
} from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas";
@ -279,6 +280,7 @@ const drawElementOnCanvas = (
}
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "diamond":
case "ellipse": {
@ -601,6 +603,7 @@ export const renderElement = (
appState: StaticCanvasAppState,
) => {
switch (element.type) {
case "magicframe":
case "frame": {
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
context.save();
@ -613,6 +616,12 @@ export const renderElement = (
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
appState.theme === "light" ? "#7affd7" : "#1d8264";
}
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
@ -673,6 +682,7 @@ export const renderElement = (
case "arrow":
case "image":
case "text":
case "iframe":
case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
@ -964,6 +974,7 @@ export const renderElementToSvg = (
addToRoot(g || node, element);
break;
}
case "iframe":
case "embeddable": {
// render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, true);
@ -1265,7 +1276,8 @@ export const renderElementToSvg = (
break;
}
// frames are not rendered and only acts as a container
case "frame": {
case "frame":
case "magicframe": {
if (
renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline

View File

@ -16,7 +16,7 @@ import {
NonDeleted,
GroupId,
ExcalidrawBindableElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
} from "../element/types";
import {
getElementAbsoluteCoords,
@ -70,11 +70,12 @@ import {
import { renderSnaps } from "./renderSnaps";
import {
isEmbeddableElement,
isFrameElement,
isFrameLikeElement,
isIframeLikeElement,
isLinearElement,
} from "../element/typeChecks";
import {
isEmbeddableOrLabel,
isIframeLikeOrItsLabel,
createPlaceholderEmbeddableLabel,
} from "../element/embeddable";
import {
@ -362,7 +363,7 @@ const renderLinearElementPointHighlight = (
};
const frameClip = (
frame: ExcalidrawFrameElement,
frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
@ -515,7 +516,7 @@ const _renderInteractiveScene = ({
}
const isFrameSelected = selectedElements.some((element) =>
isFrameElement(element),
isFrameLikeElement(element),
);
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
@ -963,7 +964,7 @@ const _renderStaticScene = ({
// Paint visible elements
visibleElements
.filter((el) => !isEmbeddableOrLabel(el))
.filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => {
try {
const frameId = element.frameId || appState.frameToHighlight?.id;
@ -996,15 +997,16 @@ const _renderStaticScene = ({
// render embeddables on top
visibleElements
.filter((el) => isEmbeddableOrLabel(el))
.filter((el) => isIframeLikeOrItsLabel(el))
.forEach((element) => {
try {
const render = () => {
renderElement(element, rc, context, renderConfig, appState);
if (
isEmbeddableElement(element) &&
(isExporting || !element.validated) &&
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) && !element.validated)) &&
element.width &&
element.height
) {
@ -1242,8 +1244,10 @@ const renderBindingHighlightForBindableElement = (
case "rectangle":
case "text":
case "image":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
strokeRectWithRotation(
context,
x1 - padding,
@ -1284,7 +1288,7 @@ const renderBindingHighlightForBindableElement = (
const renderFrameHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
frame: NonDeleted<ExcalidrawFrameElement>,
frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const width = x2 - x1;
@ -1469,7 +1473,7 @@ export const renderSceneToSvg = (
};
// render elements
elements
.filter((el) => !isEmbeddableOrLabel(el))
.filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
@ -1490,7 +1494,7 @@ export const renderSceneToSvg = (
// render embeddables on top
elements
.filter((el) => isEmbeddableElement(el))
.filter((el) => isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {

View File

@ -2,15 +2,11 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
} from "../element/types";
import {
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { getNonDeletedElements, isNonDeletedElement } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks";
import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import { AppState } from "../types";
import { Assert, SameType } from "../utility-types";
@ -107,8 +103,9 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
private frames: readonly ExcalidrawFrameElement[] = [];
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
[];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
@ -179,8 +176,8 @@ class Scene {
return selectedElements;
}
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
return this.nonDeletedFrames;
getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFramesLikes;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
@ -235,18 +232,18 @@ class Scene {
mapElementIds = true,
) {
this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = [];
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
this.elementsMap.clear();
nextElements.forEach((element) => {
if (isFrameElement(element)) {
nextFrames.push(element);
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
});
this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames;
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
this.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
this.informMutation();
}
@ -277,7 +274,7 @@ class Scene {
destroy() {
this.nonDeletedElements = [];
this.elements = [];
this.nonDeletedFrames = [];
this.nonDeletedFramesLikes = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;

View File

@ -14,7 +14,12 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants";
import { isLinearElement } from "../element/typeChecks";
import {
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -78,6 +83,7 @@ export const generateRoughOptions = (
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "diamond":
case "ellipse": {
@ -109,13 +115,13 @@ export const generateRoughOptions = (
}
};
const modifyEmbeddableForRoughOptions = (
const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
) => {
if (
element.type === "embeddable" &&
(isExporting || !element.validated) &&
isIframeLikeElement(element) &&
(isExporting || (isEmbeddableElement(element) && !element.validated)) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
backgroundColor: "#d3d3d3",
fillStyle: "solid",
} as const;
} else if (isIframeElement(element)) {
return {
...element,
strokeColor: isTransparent(element.strokeColor)
? "#000000"
: element.strokeColor,
backgroundColor: isTransparent(element.backgroundColor)
? "#f4f4f6"
: element.backgroundColor,
};
}
return element;
};
@ -143,6 +159,7 @@ export const _generateElementShape = (
): Drawable | Drawable[] | null => {
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable": {
let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially
@ -159,7 +176,7 @@ export const _generateElementShape = (
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
modifyIframeLikeForRoughOptions(element, isExporting),
true,
),
);
@ -170,7 +187,7 @@ export const _generateElementShape = (
element.width,
element.height,
generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting),
modifyIframeLikeForRoughOptions(element, isExporting),
false,
),
);
@ -373,6 +390,7 @@ export const _generateElementShape = (
return shape;
}
case "frame":
case "magicframe":
case "text":
case "image": {
const shape: ElementShapes[typeof element.type] = null;

View File

@ -1,22 +1,25 @@
import { isEmbeddableElement } from "../element/typeChecks";
import { isIframeElement } from "../element/typeChecks";
import {
ExcalidrawEmbeddableElement,
ExcalidrawIframeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { ElementOrToolType } from "../types";
export const hasBackground = (type: string) =>
export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "line" ||
type === "freedraw";
export const hasStrokeColor = (type: string) =>
type !== "image" && type !== "frame";
export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe";
export const hasStrokeWidth = (type: string) =>
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
type === "arrow" ||
type === "line";
export const hasStrokeStyle = (type: string) =>
export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "arrow" ||
type === "line";
export const canChangeRoundness = (type: string) =>
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "arrow" ||
type === "line" ||
type === "diamond";
export const canHaveArrowheads = (type: string) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[],
@ -67,7 +72,7 @@ export const getElementsAtPosition = (
elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const embeddables: ExcalidrawEmbeddableElement[] = [];
const iframeLikes: ExcalidrawIframeElement[] = [];
// The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array.
// Exception being embeddables which should be on top of everything else in
@ -75,13 +80,13 @@ export const getElementsAtPosition = (
const elsAtPos = elements.filter((element) => {
const hit = !element.isDeleted && isAtPositionFn(element);
if (hit) {
if (isEmbeddableElement(element)) {
embeddables.push(element);
if (isIframeElement(element)) {
iframeLikes.push(element);
return false;
}
return true;
}
return false;
});
return elsAtPos.concat(embeddables);
return elsAtPos.concat(iframeLikes);
};

View File

@ -1,7 +1,7 @@
import rough from "roughjs/bin/rough";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
} from "../element/types";
@ -27,11 +27,16 @@ import {
updateImageCache,
} from "../element/image";
import { elementsOverlappingBBox } from "../packages/withinBounds";
import { getFrameElements, getRootElements } from "../frame";
import { isFrameElement, newTextElement } from "../element";
import {
getFrameLikeElements,
getFrameLikeTitle,
getRootElements,
} from "../frame";
import { newTextElement } from "../element";
import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
opts: Pick<AppState, "exportWithDarkMode">,
) => {
const nextElements: NonDeletedExcalidrawElement[] = [];
let frameIdx = 0;
let frameIndex = 0;
let magicFrameIndex = 0;
for (const element of elements) {
if (isFrameLikeElement(element)) {
if (isFrameElement(element)) {
frameIdx++;
frameIndex++;
} else {
magicFrameIndex++;
}
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY,
@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
text: element.name || `Frame ${frameIdx}`,
text: getFrameLikeTitle(
element,
isFrameElement(element) ? frameIndex : magicFrameIndex,
),
});
textElement.y -= textElement.height;
@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
};
const getFrameRenderingConfig = (
exportingFrame: ExcalidrawFrameElement | null,
exportingFrame: ExcalidrawFrameLikeElement | null,
frameRendering: AppState["frameRendering"] | null,
): AppState["frameRendering"] => {
frameRendering = frameRendering || getDefaultAppState().frameRendering;
@ -148,7 +161,7 @@ const prepareElementsForRender = ({
exportWithDarkMode,
}: {
elements: readonly ExcalidrawElement[];
exportingFrame: ExcalidrawFrameElement | null | undefined;
exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
frameRendering: AppState["frameRendering"];
exportWithDarkMode: AppState["exportWithDarkMode"];
}) => {
@ -184,7 +197,7 @@ export const exportToCanvas = async (
exportBackground: boolean;
exportPadding?: number;
viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null;
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
createCanvas: (
width: number,
@ -274,7 +287,7 @@ export const exportToSvg = async (
files: BinaryFiles | null,
opts?: {
renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null;
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
): Promise<SVGSVGElement> => {
const tempScene = __createSceneForElementsHack__(elements);
@ -360,7 +373,7 @@ export const exportToSvg = async (
const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
const frameElements = getFrameElements(elements);
const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = "";
for (const frame of frameElements) {

View File

@ -4,7 +4,7 @@ import {
} from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState, InteractiveCanvasAppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import {
elementOverlapsWithFrame,
getContainingFrame,
@ -27,7 +27,7 @@ export const excludeElementsInFramesFromSelection = <
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (element.type === "frame") {
if (isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
});
@ -190,7 +190,7 @@ export const getSelectedElements = (
if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (element.type === "frame") {
if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach((e) =>
elementsToInclude.push(e),
);

View File

@ -98,6 +98,7 @@ export type ElementShapes = {
rectangle: Drawable;
ellipse: Drawable;
diamond: Drawable;
iframe: Drawable;
embeddable: Drawable;
freedraw: Drawable | null;
arrow: Drawable[];
@ -105,4 +106,5 @@ export type ElementShapes = {
text: null;
image: null;
frame: null;
magicframe: null;
};

View File

@ -83,14 +83,6 @@ export const SHAPES = [
numericKey: KEYS["0"],
fillable: false,
},
// TODO: frame, create icon and set up numeric key
// {
// icon: RectangleIcon,
// value: "frame",
// key: KEYS.F,
// numericKey: KEYS.SUBTRACT,
// fillable: false,
// },
] as const;
export const findShapeByKey = (key: string) => {

View File

@ -1,3 +1,4 @@
import { TOOL_TYPE } from "./constants";
import {
Bounds,
getCommonBounds,
@ -5,7 +6,7 @@ import {
getElementAbsoluteCoords,
} from "./element/bounds";
import { MaybeTransformHandleType } from "./element/transformHandles";
import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@ -262,7 +263,7 @@ const getReferenceElements = (
appState: AppState,
) => {
const selectedFrames = selectedElements
.filter((element) => isFrameElement(element))
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id);
return getVisibleAndNonSelectedElements(
@ -1352,10 +1353,11 @@ export const isActiveToolNonLinearSnappable = (
activeToolType: AppState["activeTool"]["type"],
) => {
return (
activeToolType === "rectangle" ||
activeToolType === "ellipse" ||
activeToolType === "diamond" ||
activeToolType === "frame" ||
activeToolType === "image"
activeToolType === TOOL_TYPE.rectangle ||
activeToolType === TOOL_TYPE.ellipse ||
activeToolType === TOOL_TYPE.diamond ||
activeToolType === TOOL_TYPE.frame ||
activeToolType === TOOL_TYPE.magicframe ||
activeToolType === TOOL_TYPE.image
);
};

View File

@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
<Excalidraw
initialData={{
appState: {
openDialog: "mermaid",
openDialog: { name: "ttd", tab: "mermaid" },
},
}}
/>,
@ -110,16 +110,16 @@ describe("Test <MermaidToExcalidraw/>", () => {
});
it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const dialog = document.querySelector(".ttd-dialog")!;
await waitFor(() => dialog.querySelector("canvas"));
expect(dialog.outerHTML).toMatchSnapshot();
});
it("should close the popup and set the tool to selection when close button clicked", () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const dialog = document.querySelector(".ttd-dialog")!;
const closeBtn = dialog.querySelector(".Dialog__close")!;
fireEvent.click(closeBtn);
expect(document.querySelector(".dialog-mermaid")).toBe(null);
expect(document.querySelector(".ttd-dialog")).toBe(null);
expect(window.h.state.activeTool).toStrictEqual({
customType: null,
lastActiveTool: null,
@ -129,9 +129,12 @@ describe("Test <MermaidToExcalidraw/>", () => {
});
it("should show error in preview when mermaid library throws error", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const selector = ".dialog-mermaid-panels-text textarea";
let editor = await getTextEditor(selector, false);
const dialog = document.querySelector(".ttd-dialog")!;
expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true);
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
@ -151,17 +154,8 @@ describe("Test <MermaidToExcalidraw/>", () => {
editor = await getTextEditor(selector, false);
expect(editor.textContent).toBe("flowchart TD1");
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
.toMatchInlineSnapshot(`
<div
class="mermaid-error"
data-testid="mermaid-error"
>
Error!
<p>
ERROR
</p>
</div>
`);
expect(
dialog.querySelector('[data-testid="mermaid-error"]'),
).toMatchInlineSnapshot("null");
});
});

View File

@ -1,10 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD
"<div class=\\"Modal Dialog ttd-dialog\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div dir=\\"ltr\\" data-orientation=\\"horizontal\\" class=\\"ttd-dialog-tabs-root\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><div data-state=\\"active\\" data-orientation=\\"horizontal\\" role=\\"tabpanel\\" aria-labelledby=\\"radix-:r0:-trigger-mermaid\\" id=\\"radix-:r0:-content-mermaid\\" tabindex=\\"0\\" class=\\"ttd-dialog-content\\" style=\\"animation-duration: 0s;\\"><div class=\\"ttd-dialog-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.</div><div class=\\"ttd-dialog-panels\\"><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Mermaid Syntax</label></div><textarea class=\\"ttd-dialog-input\\" placeholder=\\"Write Mermaid diagram defintion here...\\">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
C --&gt;|Three| F[Car]</textarea><div class=\\"ttd-dialog-panel-button-container invisible\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\"></div></button></div></div><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Preview</label></div><div class=\\"ttd-dialog-output-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"ttd-dialog-output-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div><div class=\\"ttd-dialog-panel-button-container\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></div></button></div></div></div></div></div></div></div></div></div>"
`;

View File

@ -7,6 +7,8 @@ import {
ExcalidrawImageElement,
FileId,
ExcalidrawFrameElement,
ExcalidrawElementType,
ExcalidrawMagicFrameElement,
} from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@ -29,7 +31,9 @@ import {
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
newIframeElement,
newImageElement,
newMagicFrameElement,
} from "../../element/newElement";
import { Point } from "../../types";
import { getSelectedElements } from "../../scene/selection";
@ -96,7 +100,7 @@ export class API {
};
static createElement = <
T extends Exclude<ExcalidrawElement["type"], "selection"> = "rectangle",
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
>({
// @ts-ignore
type = "rectangle",
@ -163,6 +167,8 @@ export class API {
? ExcalidrawImageElement
: T extends "frame"
? ExcalidrawFrameElement
: T extends "magicframe"
? ExcalidrawMagicFrameElement
: ExcalidrawGenericElement => {
let element: Mutable<ExcalidrawElement> = null!;
@ -235,6 +241,12 @@ export class API {
validated: null,
});
break;
case "iframe":
element = newIframeElement({
type: "iframe",
...base,
});
break;
case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
@ -286,6 +298,9 @@ export class API {
case "frame":
element = newFrameElement({ ...base, width, height });
break;
case "magicframe":
element = newMagicFrameElement({ ...base, width, height });
break;
default:
assertNever(
type,

View File

@ -1,4 +1,4 @@
import type { Point } from "../../types";
import type { Point, ToolType } from "../../types";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -20,15 +20,14 @@ import {
type TransformHandleDirection,
} from "../../element/transformHandles";
import { KEYS } from "../../keys";
import { type ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState, screen } from "../test-utils";
import { mutateElement } from "../../element/mutateElement";
import { API } from "./api";
import {
isFrameElement,
isLinearElement,
isFreeDrawElement,
isTextElement,
isFrameLikeElement,
} from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math";
@ -290,7 +289,7 @@ const transform = (
];
} else {
const [x1, y1, x2, y2] = getCommonBounds(elements);
const isFrameSelected = elements.some(isFrameElement);
const isFrameSelected = elements.some(isFrameLikeElement);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
@ -345,7 +344,7 @@ const proxy = <T extends ExcalidrawElement>(
};
/** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolName, "lock" | "selection" | "eraser">;
type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement
@ -362,7 +361,7 @@ type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
: ExcalidrawElement;
export class UI {
static clickTool = (toolName: ToolName) => {
static clickTool = (toolName: ToolType | "lock") => {
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
};

View File

@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(3);
@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);
@ -519,7 +519,7 @@ describe("Test Linear Elements", () => {
// delete 3rd point
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene).toHaveBeenCalledTimes(20);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
@ -566,7 +566,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);

View File

@ -1,23 +1,9 @@
import { queries, buildQueries } from "@testing-library/react";
import { ToolType } from "../../types";
import { TOOL_TYPE } from "../../constants";
const toolMap = {
lock: "lock",
selection: "selection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
eraser: "eraser",
frame: "frame",
};
export type ToolName = keyof typeof toolMap;
const _getAllByToolName = (container: HTMLElement, tool: string) => {
const toolTitle = toolMap[tool as ToolName];
const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool];
return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
};
@ -32,7 +18,7 @@ export const [
getByToolName,
findAllByToolName,
findByToolName,
] = buildQueries<string[]>(
] = buildQueries<(ToolType | "lock")[]>(
_getAllByToolName,
getMultipleError,
getMissingError,

View File

@ -15,8 +15,10 @@ import {
ExcalidrawImageElement,
Theme,
StrokeRoundness,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
} from "./element/types";
import { Action } from "./actions/types";
import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -108,9 +110,12 @@ export type ToolType =
| "eraser"
| "hand"
| "frame"
| "magicframe"
| "embeddable"
| "laser";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
export type ActiveTool =
| {
type: ToolType;
@ -165,9 +170,6 @@ export type InteractiveCanvasAppState = Readonly<
suggestedBindings: AppState["suggestedBindings"];
isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"];
// App
openSidebar: AppState["openSidebar"];
showHyperlinkPopup: AppState["showHyperlinkPopup"];
// Collaborators
collaborators: AppState["collaborators"];
// SnapLines
@ -176,7 +178,7 @@ export type InteractiveCanvasAppState = Readonly<
}
>;
export type AppState = {
export interface AppState {
contextMenu: {
items: ContextMenuItems;
top: number;
@ -196,7 +198,7 @@ export type AppState = {
isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[];
frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
frameRendering: {
enabled: boolean;
name: boolean;
@ -252,7 +254,18 @@ export type AppState = {
openMenu: "canvas" | "shape" | null;
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null;
openDialog:
| null
| { name: "imageExport" | "help" | "jsonExport" }
| {
name: "settings";
source:
| "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked
tab: "text-to-diagram" | "diagram-to-code";
}
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" };
/**
* Reflects user preference for whether the default sidebar should be docked.
*
@ -307,7 +320,7 @@ export type AppState = {
y: number;
} | null;
objectsSnapModeEnabled: boolean;
};
}
export type UIAppState = Omit<
AppState,
@ -447,6 +460,7 @@ export interface ExcalidrawProps {
element: NonDeleted<ExcalidrawEmbeddableElement>,
appState: AppState,
) => JSX.Element | null;
aiEnabled?: boolean;
}
export type SceneData = {
@ -515,6 +529,7 @@ export type AppProps = Merge<
handleKeyboardGlobally: boolean;
isCollaborating: boolean;
children?: React.ReactNode;
aiEnabled: boolean;
}
>;
@ -548,6 +563,8 @@ export type AppClassProperties = {
togglePenMode: App["togglePenMode"];
setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicframeToolSelect: App["onMagicframeToolSelect"];
};
export type PointerDownState = Readonly<{
@ -695,12 +712,14 @@ type FrameNameBounds = {
};
export type FrameNameBoundsCache = {
get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
get: (
frameElement: ExcalidrawFrameLikeElement | ExcalidrawMagicFrameElement,
) => FrameNameBounds | null;
_cache: Map<
string,
FrameNameBounds & {
zoom: AppState["zoom"]["value"];
versionNonce: ExcalidrawFrameElement["versionNonce"];
versionNonce: ExcalidrawFrameLikeElement["versionNonce"];
}
>;
};
@ -720,3 +739,5 @@ export type Primitive =
| symbol
| null
| undefined;
export type JSONValue = string | number | boolean | null | object;

Some files were not shown because too many files have changed in this diff Show More