feat: wireframe-to-code (#7334)

This commit is contained in:
David Luzar
2023-11-23 23:07:53 +01:00
committed by GitHub
parent d1e4421823
commit c7ee46e7f8
63 changed files with 2106 additions and 444 deletions

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 {
@ -36,6 +36,8 @@ import {
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
@ -79,7 +81,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) {
@ -94,7 +97,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>
@ -331,6 +335,9 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon}
@ -338,6 +345,25 @@ export const ShapesSwitcher = ({
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicButtonSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("magicSettings")}
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

@ -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";
@ -56,6 +61,7 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
interface LayerUIProps {
actionManager: ActionManager;
@ -77,6 +83,10 @@ 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) => void;
}
const DefaultMainMenu: React.FC<{
@ -133,6 +143,10 @@ const LayerUI = ({
children,
app,
isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -295,9 +309,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
/>
@ -439,6 +455,20 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
setAppState({ openDialog: null });
onMagicSettingsConfirm(apiKey, shouldPersist);
}}
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,9 @@
.excalidraw {
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
}
}

View File

@ -0,0 +1,145 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import "./MagicSettings.scss";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
const InlineButton = ({ 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>
);
};
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const { theme } = useUIAppState();
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Diagram to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: theme === "light" ? "#FFCCCC" : "#703333",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
<p
style={{
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
For the diagram-to-code feature we use{" "}
<InlineButton icon={OpenAIIcon} />
OpenAI.
</p>
<p>
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>
.
</p>
<p>
Your OpenAI key does not leave the browser, and you can also set your
own limit in your OpenAI account dashboard if needed.
</p>
<TextField
isPassword
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()}
/>
<p>
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.
</p>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<p>
Once API key is set, you can use the <InlineButton 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>{" "}
<InlineButton icon={OpenAIIcon} />.
</p>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</Dialog>
);
};

View File

@ -94,7 +94,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,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;
isPassword?: boolean;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly,
selectOnRender,
onKeyDown,
isPassword = false,
},
ref,
) => {
@ -48,6 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
}
}, [selectOnRender]);
const [isVisible, setIsVisible] = useState<boolean>(true);
return (
<div
className={clsx("ExcTextField", {
@ -64,14 +71,22 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})}
>
<input
type={isPassword && isVisible ? "password" : undefined}
readOnly={readonly}
type="text"
value={value}
placeholder={placeholder}
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
{isPassword && (
<Button
onSelect={() => setIsVisible(!isVisible)}
style={{ border: 0 }}
>
{isVisible ? eyeIcon : eyeClosedIcon}
</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

@ -1688,3 +1688,57 @@ 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,
);