feat: d2c tweaks (#7336)

This commit is contained in:
David Luzar 2023-11-24 14:02:11 +01:00 committed by GitHub
parent c7ee46e7f8
commit 3d1631f375
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 168 additions and 74 deletions

View File

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

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

View File

@ -339,7 +339,7 @@ export const ShapesSwitcher = ({
Generate Generate
</div> </div>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")} onSelect={() => app.setOpenDialog({ name: "mermaid" })}
icon={mermaidLogoIcon} icon={mermaidLogoIcon}
data-testid="toolbar-embeddable" data-testid="toolbar-embeddable"
> >
@ -349,14 +349,20 @@ export const ShapesSwitcher = ({
{app.props.aiEnabled !== false && ( {app.props.aiEnabled !== false && (
<> <>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.onMagicButtonSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setOpenDialog("magicSettings")} onSelect={() => {
trackEvent("ai", "d2c-settings", "settings");
app.setOpenDialog({
name: "magicSettings",
source: "settings",
});
}}
icon={OpenAIIcon} icon={OpenAIIcon}
data-testid="toolbar-magicSettings" data-testid="toolbar-magicSettings"
> >

View File

@ -1435,7 +1435,7 @@ class App extends React.Component<AppProps, AppState> {
onMagicSettingsConfirm={this.onMagicSettingsConfirm} onMagicSettingsConfirm={this.onMagicSettingsConfirm}
> >
{this.props.children} {this.props.children}
{this.state.openDialog === "mermaid" && ( {this.state.openDialog?.name === "mermaid" && (
<MermaidToExcalidraw /> <MermaidToExcalidraw />
)} )}
</LayerUI> </LayerUI>
@ -1467,6 +1467,7 @@ class App extends React.Component<AppProps, AppState> {
onChange={() => onChange={() =>
this.onMagicFrameGenerate( this.onMagicFrameGenerate(
firstSelectedElement, firstSelectedElement,
"button",
) )
} }
/> />
@ -1697,11 +1698,15 @@ class App extends React.Component<AppProps, AppState> {
return text; return text;
} }
private async onMagicFrameGenerate(magicFrame: ExcalidrawMagicFrameElement) { private async onMagicFrameGenerate(
magicFrame: ExcalidrawMagicFrameElement,
source: "button" | "upstream",
) {
if (!this.OPENAI_KEY) { if (!this.OPENAI_KEY) {
this.setState({ this.setState({
openDialog: "magicSettings", openDialog: { name: "magicSettings", source: "generation" },
}); });
trackEvent("ai", "d2c-generate", "missing-key");
return; return;
} }
@ -1712,7 +1717,12 @@ class App extends React.Component<AppProps, AppState> {
}).filter((el) => !isMagicFrameElement(el)); }).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) { if (!magicFrameChildren.length) {
this.setState({ errorMessage: "Cannot generate from an empty frame" }); if (source === "button") {
this.setState({ errorMessage: "Cannot generate from an empty frame" });
trackEvent("ai", "d2c-generate", "no-children");
} else {
this.setActiveTool({ type: "magicframe" });
}
return; return;
} }
@ -1751,6 +1761,8 @@ class App extends React.Component<AppProps, AppState> {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren); const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
trackEvent("ai", "d2c-generate", "generating");
const result = await diagramToHTML({ const result = await diagramToHTML({
image: dataURL, image: dataURL,
apiKey: this.OPENAI_KEY, apiKey: this.OPENAI_KEY,
@ -1759,6 +1771,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (!result.ok) { if (!result.ok) {
trackEvent("ai", "d2c-generate", "generating-failed");
console.error(result.error); console.error(result.error);
this.updateMagicGeneration({ this.updateMagicGeneration({
frameElement, frameElement,
@ -1770,6 +1783,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
return; return;
} }
trackEvent("ai", "d2c-generate", "generating-done");
if (result.choices[0].message.content == null) { if (result.choices[0].message.content == null) {
this.updateMagicGeneration({ this.updateMagicGeneration({
@ -1813,7 +1827,10 @@ class App extends React.Component<AppProps, AppState> {
private OPENAI_KEY_IS_PERSISTED: boolean = private OPENAI_KEY_IS_PERSISTED: boolean =
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false; EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
private onOpenAIKeyChange = (openAIKey: string, shouldPersist: boolean) => { private onOpenAIKeyChange = (
openAIKey: string | null,
shouldPersist: boolean,
) => {
this.OPENAI_KEY = openAIKey || null; this.OPENAI_KEY = openAIKey || null;
if (shouldPersist) { if (shouldPersist) {
const didPersist = EditorLocalStorage.set( const didPersist = EditorLocalStorage.set(
@ -1826,26 +1843,41 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private onMagicSettingsConfirm = (apiKey: string, shouldPersist: boolean) => { private onMagicSettingsConfirm = (
this.onOpenAIKeyChange(apiKey, shouldPersist); apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => {
this.OPENAI_KEY = apiKey || null;
this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist);
if (source === "settings") {
return;
}
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
if (apiKey) { if (apiKey) {
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
if (selectedElements.length) { if (selectedElements.length) {
this.onMagicButtonSelect(); this.onMagicframeToolSelect();
} else {
this.setActiveTool({ type: "magicframe" });
} }
} else { } else if (!isMagicFrameElement(selectedElements[0])) {
this.OPENAI_KEY = null; // even if user didn't end up setting api key, let's pick the tool
// so they can draw up a frame and move forward
this.setActiveTool({ type: "magicframe" });
} }
}; };
public onMagicButtonSelect = () => { public onMagicframeToolSelect = () => {
if (!this.OPENAI_KEY) { if (!this.OPENAI_KEY) {
this.setState({ this.setState({
openDialog: "magicSettings", openDialog: { name: "magicSettings", source: "tool" },
}); });
trackEvent("ai", "d2c-tool", "missing-key");
return; return;
} }
@ -1855,19 +1887,33 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 0) { if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe }); this.setActiveTool({ type: TOOL_TYPE.magicframe });
trackEvent("ai", "d2c-tool", "empty-selection");
} else { } else {
if (selectedElements.some((el) => isFrameLikeElement(el))) { const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
selectedElements.length === 1 &&
isMagicFrameElement(selectedElements[0]) &&
selectedElements[0];
// case: user selected elements containing frame-like(s) or are frame
// members, we don't want to wrap into another magicframe
// (unless the only selected element is a magic frame which we reuse)
if (
!selectedMagicFrame &&
selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
) {
this.setActiveTool({ type: TOOL_TYPE.magicframe }); this.setActiveTool({ type: TOOL_TYPE.magicframe });
return; return;
} }
let frame: ExcalidrawMagicFrameElement | null = null; trackEvent("ai", "d2c-tool", "existing-selection");
if (
selectedElements.length === 1 && let frame: ExcalidrawMagicFrameElement;
isMagicFrameElement(selectedElements[0]) if (selectedMagicFrame) {
) { // a single magicframe already selected -> use it
frame = selectedElements[0]; frame = selectedMagicFrame;
} else { } else {
// selected elements aren't wrapped in magic frame yet -> wrap now
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements); const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const padding = 50; const padding = 50;
@ -1880,19 +1926,19 @@ class App extends React.Component<AppProps, AppState> {
opacity: 100, opacity: 100,
locked: false, locked: false,
}); });
this.scene.addNewElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
} }
this.scene.addNewElement(frame); this.onMagicFrameGenerate(frame, "upstream");
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
this.onMagicFrameGenerate(frame);
} }
}; };
@ -3551,7 +3597,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) { if (event.key === KEYS.QUESTION_MARK) {
this.setState({ this.setState({
openDialog: "help", openDialog: { name: "help" },
}); });
return; return;
} else if ( } else if (
@ -3560,7 +3606,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] event[KEYS.CTRL_OR_CMD]
) { ) {
event.preventDefault(); event.preventDefault();
this.setState({ openDialog: "imageExport" }); this.setState({ openDialog: { name: "imageExport" } });
return; return;
} }

View File

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

View File

@ -86,7 +86,11 @@ interface LayerUIProps {
openAIKey: string | null; openAIKey: string | null;
isOpenAIKeyPersisted: boolean; isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void; onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void; onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@ -177,7 +181,7 @@ const LayerUI = ({
const renderImageExportDialog = () => { const renderImageExportDialog = () => {
if ( if (
!UIOptions.canvasActions.saveAsImage || !UIOptions.canvasActions.saveAsImage ||
appState.openDialog !== "imageExport" appState.openDialog?.name !== "imageExport"
) { ) {
return null; return null;
} }
@ -448,21 +452,26 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog === "help" && ( {appState.openDialog?.name === "help" && (
<HelpDialog <HelpDialog
onClose={() => { onClose={() => {
setAppState({ openDialog: null }); setAppState({ openDialog: null });
}} }}
/> />
)} )}
{appState.openDialog === "magicSettings" && ( {appState.openDialog?.name === "magicSettings" && (
<MagicSettings <MagicSettings
openAIKey={openAIKey} openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted} isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange} onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => { onConfirm={(apiKey, shouldPersist) => {
setAppState({ openDialog: null }); const source =
onMagicSettingsConfirm(apiKey, shouldPersist); appState.openDialog?.name === "magicSettings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
}} }}
onClose={() => { onClose={() => {
setAppState({ openDialog: null }); setAppState({ openDialog: null });

View File

@ -106,7 +106,7 @@ export const MagicSettings = (props: {
own limit in your OpenAI account dashboard if needed. own limit in your OpenAI account dashboard if needed.
</p> </p>
<TextField <TextField
isPassword isRedacted
value={keyInputValue} value={keyInputValue}
placeholder="Paste your API key here" placeholder="Paste your API key here"
label="OpenAI API key" label="OpenAI API key"

View File

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

View File

@ -25,7 +25,7 @@ type TextFieldProps = {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
isPassword?: boolean; isRedacted?: boolean;
}; };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@ -39,7 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly, readonly,
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isPassword = false, isRedacted = false,
}, },
ref, ref,
) => { ) => {
@ -53,7 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
} }
}, [selectOnRender]); }, [selectOnRender]);
const [isVisible, setIsVisible] = useState<boolean>(true); const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
useState<boolean>(false);
return ( return (
<div <div
@ -71,7 +72,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})} })}
> >
<input <input
type={isPassword && isVisible ? "password" : undefined} className={clsx({
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
})}
readOnly={readonly} readOnly={readonly}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
@ -79,12 +82,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{isPassword && ( {isRedacted && (
<Button <Button
onSelect={() => setIsVisible(!isVisible)} onSelect={() =>
style={{ border: 0 }} setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
}
style={{ border: 0, userSelect: "none" }}
> >
{isVisible ? eyeIcon : eyeClosedIcon} {isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
</Button> </Button>
)} )}
</div> </div>

View File

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

View File

@ -533,6 +533,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"], input[type="text"],
textarea:not(.excalidraw-wysiwyg) { textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color); color: var(--text-primary-color);

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. 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) ## 0.17.0 (2023-11-14)
### Features ### Features

View File

@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
<Excalidraw <Excalidraw
initialData={{ initialData={{
appState: { appState: {
openDialog: "mermaid", openDialog: { name: "mermaid" },
}, },
}} }}
/>, />,

View File

@ -245,12 +245,15 @@ export interface AppState {
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null; openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: openDialog:
| "imageExport" | null
| "help" | { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
| "jsonExport" | {
| "mermaid" name: "magicSettings";
| "magicSettings" source:
| null; | "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked
};
/** /**
* Reflects user preference for whether the default sidebar should be docked. * Reflects user preference for whether the default sidebar should be docked.
* *
@ -549,7 +552,7 @@ export type AppClassProperties = {
setActiveTool: App["setActiveTool"]; setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"]; setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"]; insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicButtonSelect: App["onMagicButtonSelect"]; onMagicframeToolSelect: App["onMagicframeToolSelect"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{