mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-06-05 21:04:43 +08:00
♻️ Move editable components to shared UI package
This commit is contained in:
parent
a2ee915628
commit
ece99ba625
@ -1,106 +0,0 @@
|
||||
import { Input, type InputProps } from "@typebot.io/ui/components/Input";
|
||||
import { cn } from "@typebot.io/ui/lib/cn";
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useOutsideClick } from "@/hooks/useOutsideClick";
|
||||
|
||||
export type SingleLineEditableProps = {
|
||||
className?: string;
|
||||
input?: Omit<InputProps, "value">;
|
||||
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
common?: HTMLAttributes<HTMLButtonElement | HTMLInputElement>;
|
||||
defaultEdit?: boolean;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueCommit: (value: string) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SingleLineEditable = forwardRef<
|
||||
HTMLDivElement,
|
||||
SingleLineEditableProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
input,
|
||||
preview,
|
||||
common,
|
||||
value,
|
||||
defaultValue,
|
||||
defaultEdit,
|
||||
onValueCommit,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isEditing, setIsEditing] = useState(defaultEdit ?? false);
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? "");
|
||||
|
||||
const commitValue = () => {
|
||||
setIsEditing(false);
|
||||
onValueCommit(value ?? currentValue);
|
||||
};
|
||||
|
||||
useOutsideClick({
|
||||
ref: inputRef,
|
||||
capture: true,
|
||||
handler: commitValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...props} ref={ref}>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
{...input}
|
||||
ref={inputRef}
|
||||
onKeyDownCapture={(e) => {
|
||||
input?.onKeyDownCapture?.(e);
|
||||
if (e.key === "Enter") commitValue();
|
||||
if (e.key === "Escape") setIsEditing(false);
|
||||
}}
|
||||
size="none"
|
||||
value={value ?? currentValue}
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onValueChange={(value, e) => {
|
||||
setCurrentValue(value);
|
||||
input?.onValueChange?.(value, e);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded-md border",
|
||||
common?.className,
|
||||
input?.className,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
{...preview}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
preview?.onClick?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-gray-3 inline-flex w-full p-1 border border-transparent rounded-md cursor-pointer",
|
||||
common?.className,
|
||||
preview?.className,
|
||||
)}
|
||||
>
|
||||
{value ?? currentValue}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SingleLineEditable.displayName = "SingleLineEditable";
|
||||
@ -8,10 +8,10 @@ import { convertStrToList } from "@typebot.io/lib/convertStrToList";
|
||||
import { isEmpty } from "@typebot.io/lib/utils";
|
||||
import { Button } from "@typebot.io/ui/components/Button";
|
||||
import { Popover } from "@typebot.io/ui/components/Popover";
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { Settings01Icon } from "@typebot.io/ui/icons/Settings01Icon";
|
||||
import { cx } from "@typebot.io/ui/lib/cva";
|
||||
import { useState } from "react";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
import { useTypebot } from "@/features/editor/providers/TypebotProvider";
|
||||
import { useGraph } from "@/features/graph/providers/GraphProvider";
|
||||
import { ButtonsItemSettings } from "./ButtonsItemSettings";
|
||||
|
||||
@ -6,21 +6,21 @@ import type {
|
||||
} from "@typebot.io/blocks-core/schemas/items/schema";
|
||||
import type { CardsItem } from "@typebot.io/blocks-inputs/cards/schema";
|
||||
import { Button } from "@typebot.io/ui/components/Button";
|
||||
import {
|
||||
MultiLineEditable,
|
||||
type MultiLineEditableProps,
|
||||
} from "@typebot.io/ui/components/MultiLineEditable";
|
||||
import { Popover } from "@typebot.io/ui/components/Popover";
|
||||
import {
|
||||
SingleLineEditable,
|
||||
type SingleLineEditableProps,
|
||||
} from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { Settings01Icon } from "@typebot.io/ui/icons/Settings01Icon";
|
||||
import { cn } from "@typebot.io/ui/lib/cn";
|
||||
import { cx } from "@typebot.io/ui/lib/cva";
|
||||
import { useState } from "react";
|
||||
import { ImageOrPlaceholder } from "@/components/ImageOrPlaceholder";
|
||||
import { ImageUploadContent } from "@/components/ImageUploadContent/ImageUploadContent";
|
||||
import {
|
||||
MultiLineEditable,
|
||||
type MultiLineEditableProps,
|
||||
} from "@/components/MultiLineEditable";
|
||||
import {
|
||||
SingleLineEditable,
|
||||
type SingleLineEditableProps,
|
||||
} from "@/components/SingleLineEditable";
|
||||
import {
|
||||
GhostableItem,
|
||||
StacksWithGhostableItems,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { Tooltip } from "@typebot.io/ui/components/Tooltip";
|
||||
import { useState } from "react";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
|
||||
type EditableProps = {
|
||||
defaultName: string;
|
||||
|
||||
@ -5,6 +5,7 @@ import { Alert } from "@typebot.io/ui/components/Alert";
|
||||
import { AlertDialog } from "@typebot.io/ui/components/AlertDialog";
|
||||
import { Button, buttonVariants } from "@typebot.io/ui/components/Button";
|
||||
import { Menu } from "@typebot.io/ui/components/Menu";
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { Skeleton } from "@typebot.io/ui/components/Skeleton";
|
||||
import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls";
|
||||
import { Folder01SolidIcon } from "@typebot.io/ui/icons/Folder01SolidIcon";
|
||||
@ -13,7 +14,6 @@ import { TriangleAlertIcon } from "@typebot.io/ui/icons/TriangleAlertIcon";
|
||||
import { cn } from "@typebot.io/ui/lib/cn";
|
||||
import { useRouter } from "next/router";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
import { orpc } from "@/lib/queryClient";
|
||||
import { useTypebotDnd } from "../TypebotDndProvider";
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { GroupV6 } from "@typebot.io/groups/schemas";
|
||||
import { isEmpty, isNotDefined } from "@typebot.io/lib/utils";
|
||||
import { ContextMenu } from "@typebot.io/ui/components/ContextMenu";
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { cx } from "@typebot.io/ui/lib/cva";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
import { useEditor } from "@/features/editor/providers/EditorProvider";
|
||||
import { useTypebot } from "@/features/editor/providers/TypebotProvider";
|
||||
import { groupWidth } from "@/features/graph/constants";
|
||||
|
||||
@ -10,6 +10,7 @@ import { Field } from "@typebot.io/ui/components/Field";
|
||||
import { Input } from "@typebot.io/ui/components/Input";
|
||||
import { MoreInfoTooltip } from "@typebot.io/ui/components/MoreInfoTooltip";
|
||||
import { Popover } from "@typebot.io/ui/components/Popover";
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { Switch } from "@typebot.io/ui/components/Switch";
|
||||
import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls";
|
||||
import { Cancel01Icon } from "@typebot.io/ui/icons/Cancel01Icon";
|
||||
@ -19,7 +20,6 @@ import { TrashIcon } from "@typebot.io/ui/icons/TrashIcon";
|
||||
import type { Variable } from "@typebot.io/variables/schemas";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { headerHeight } from "../../editor/constants";
|
||||
import { useTypebot } from "../../editor/providers/TypebotProvider";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { SingleLineEditable } from "@typebot.io/ui/components/SingleLineEditable";
|
||||
import { useState } from "react";
|
||||
import { CopyButton } from "@/components/CopyButton";
|
||||
import { SingleLineEditable } from "@/components/SingleLineEditable";
|
||||
|
||||
type EditableUrlProps = {
|
||||
hostname: string;
|
||||
|
||||
@ -44,17 +44,19 @@ export const handleSaveThemeTemplate = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const themeTemplate = (existingThemeTemplate
|
||||
? await prisma.themeTemplate.update({
|
||||
where: { id: themeTemplateId },
|
||||
data,
|
||||
})
|
||||
: await prisma.themeTemplate.create({
|
||||
data: {
|
||||
...data,
|
||||
workspaceId,
|
||||
},
|
||||
})) as ThemeTemplate;
|
||||
const themeTemplate = (
|
||||
existingThemeTemplate
|
||||
? await prisma.themeTemplate.update({
|
||||
where: { id: themeTemplateId },
|
||||
data,
|
||||
})
|
||||
: await prisma.themeTemplate.create({
|
||||
data: {
|
||||
...data,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
) as ThemeTemplate;
|
||||
|
||||
return {
|
||||
themeTemplate,
|
||||
|
||||
4
bun.lock
4
bun.lock
@ -602,7 +602,7 @@
|
||||
},
|
||||
"packages/embeds/js": {
|
||||
"name": "@typebot.io/js",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.23",
|
||||
"devDependencies": {
|
||||
"@ai-sdk/ui-utils": "^1.2.11",
|
||||
"@ark-ui/solid": "^5.19.0",
|
||||
@ -637,7 +637,7 @@
|
||||
},
|
||||
"packages/embeds/react": {
|
||||
"name": "@typebot.io/react",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.23",
|
||||
"dependencies": {
|
||||
"@typebot.io/js": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@ -37,7 +37,10 @@
|
||||
],
|
||||
"bundle": true,
|
||||
"thirdParty": true,
|
||||
"external": ["react", "react/jsx-runtime"],
|
||||
"external": [
|
||||
"react",
|
||||
"react/jsx-runtime"
|
||||
],
|
||||
"declaration": true
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { Textarea } from "@typebot.io/ui/components/Textarea";
|
||||
import { cn } from "@typebot.io/ui/lib/cn";
|
||||
import { type ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { useOutsideClick } from "@/hooks/useOutsideClick";
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { cn } from "../lib/cn";
|
||||
import { Textarea } from "./Textarea";
|
||||
|
||||
export type MultiLineEditableProps = {
|
||||
className?: string;
|
||||
input?: Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
"value" | "onChange"
|
||||
> & {
|
||||
onValueChange?: (value: string) => void;
|
||||
@ -17,7 +23,7 @@ export type MultiLineEditableProps = {
|
||||
onValueCommit: (value: string) => void;
|
||||
};
|
||||
|
||||
const ADDITIONAL_FOCUS_HEIGHT = 20 as const;
|
||||
const additionalFocusHeight = 20;
|
||||
|
||||
export const MultiLineEditable = ({
|
||||
input,
|
||||
@ -28,23 +34,26 @@ export const MultiLineEditable = ({
|
||||
...props
|
||||
}: MultiLineEditableProps) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const autoResize = (textarea: HTMLTextAreaElement) => {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight + ADDITIONAL_FOCUS_HEIGHT}px`;
|
||||
};
|
||||
|
||||
const handleMouseWheel = (e: WheelEvent) => {
|
||||
e.stopPropagation();
|
||||
const autoResize = (textareaElement: HTMLTextAreaElement) => {
|
||||
textareaElement.style.height = "auto";
|
||||
textareaElement.style.height = `${textareaElement.scrollHeight + additionalFocusHeight}px`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.addEventListener("wheel", handleMouseWheel);
|
||||
const textareaElement = textareaRef.current;
|
||||
if (!textareaElement) return;
|
||||
|
||||
return () => {
|
||||
textareaRef.current?.addEventListener("wheel", handleMouseWheel);
|
||||
const stopMouseWheelPropagation = (event: WheelEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
});
|
||||
|
||||
textareaElement.addEventListener("wheel", stopMouseWheelPropagation);
|
||||
return () => {
|
||||
textareaElement.removeEventListener("wheel", stopMouseWheelPropagation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(defaultEdit);
|
||||
|
||||
const commitValue = () => {
|
||||
@ -63,18 +72,18 @@ export const MultiLineEditable = ({
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
{...input}
|
||||
onKeyDownCapture={(e) => {
|
||||
input?.onKeyDownCapture?.(e);
|
||||
if (e.key === "Enter" && e.metaKey) commitValue();
|
||||
if (e.key === "Escape") setIsEditing(false);
|
||||
onKeyDownCapture={(event) => {
|
||||
input?.onKeyDownCapture?.(event);
|
||||
if (event.key === "Enter" && event.metaKey) commitValue();
|
||||
if (event.key === "Escape") setIsEditing(false);
|
||||
}}
|
||||
ref={textareaRef}
|
||||
size="none"
|
||||
value={value}
|
||||
className={cn("px-1 py-1 rounded-md border w-full", input?.className)}
|
||||
onFocus={(e) => {
|
||||
autoResize(e.currentTarget);
|
||||
e.currentTarget.select();
|
||||
className={cn("w-full rounded-md border px-1 py-1", input?.className)}
|
||||
onFocus={(event) => {
|
||||
autoResize(event.currentTarget);
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onValueChange={input?.onValueChange}
|
||||
autoFocus
|
||||
@ -89,7 +98,7 @@ export const MultiLineEditable = ({
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-gray-3 inline-flex w-full p-1 border border-transparent rounded-md whitespace-pre-line cursor-pointer",
|
||||
"hover:bg-gray-3 inline-flex w-full cursor-pointer whitespace-pre-line rounded-md border border-transparent p-1",
|
||||
preview?.className,
|
||||
)}
|
||||
>
|
||||
99
packages/ui/src/components/SingleLineEditable.tsx
Normal file
99
packages/ui/src/components/SingleLineEditable.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { InputProps } from "./Input";
|
||||
import { Input } from "./Input";
|
||||
|
||||
export type SingleLineEditableProps = {
|
||||
className?: string;
|
||||
input?: Omit<InputProps, "value">;
|
||||
preview?: ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
common?: HTMLAttributes<HTMLButtonElement | HTMLInputElement>;
|
||||
defaultEdit?: boolean;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueCommit: (value: string) => void;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SingleLineEditable = ({
|
||||
input,
|
||||
preview,
|
||||
common,
|
||||
value,
|
||||
defaultValue,
|
||||
defaultEdit,
|
||||
onValueCommit,
|
||||
children,
|
||||
...props
|
||||
}: SingleLineEditableProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isEditing, setIsEditing] = useState(defaultEdit ?? false);
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? "");
|
||||
|
||||
const commitValue = () => {
|
||||
setIsEditing(false);
|
||||
onValueCommit(value ?? currentValue);
|
||||
};
|
||||
|
||||
useOutsideClick({
|
||||
ref: inputRef,
|
||||
capture: true,
|
||||
handler: commitValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
{...input}
|
||||
ref={inputRef}
|
||||
onKeyDownCapture={(event) => {
|
||||
input?.onKeyDownCapture?.(event);
|
||||
if (event.key === "Enter") commitValue();
|
||||
if (event.key === "Escape") setIsEditing(false);
|
||||
}}
|
||||
size="none"
|
||||
value={value ?? currentValue}
|
||||
autoFocus
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onValueChange={(updatedValue, event) => {
|
||||
setCurrentValue(updatedValue);
|
||||
input?.onValueChange?.(updatedValue, event);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border p-1",
|
||||
common?.className,
|
||||
input?.className,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
{...preview}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
preview?.onClick?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-gray-3 inline-flex w-full cursor-pointer rounded-md border border-transparent p-1",
|
||||
common?.className,
|
||||
preview?.className,
|
||||
)}
|
||||
>
|
||||
{value ?? currentValue}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
packages/ui/src/hooks/useOutsideClick.ts
Normal file
49
packages/ui/src/hooks/useOutsideClick.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Handler = (event: MouseEvent) => void;
|
||||
|
||||
type Props<T> = {
|
||||
ref: RefObject<T | null>;
|
||||
handler: Handler;
|
||||
capture?: boolean;
|
||||
isEnabled?: boolean;
|
||||
ignoreSelectors?: string[];
|
||||
};
|
||||
|
||||
export const useOutsideClick = <T extends HTMLElement = HTMLElement>({
|
||||
ref,
|
||||
handler,
|
||||
capture,
|
||||
isEnabled,
|
||||
ignoreSelectors,
|
||||
}: Props<T>): void => {
|
||||
useEffect(() => {
|
||||
if (isEnabled === false) return;
|
||||
|
||||
const triggerHandlerIfOutside = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Node)) return;
|
||||
const clickedNode = event.target;
|
||||
const clickedElement =
|
||||
event.target instanceof Element ? event.target : undefined;
|
||||
const element = ref.current;
|
||||
if (
|
||||
!element ||
|
||||
element.contains(clickedNode) ||
|
||||
ignoreSelectors?.some((selector) => clickedElement?.closest(selector))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", triggerHandlerIfOutside, {
|
||||
capture,
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", triggerHandlerIfOutside, {
|
||||
capture,
|
||||
});
|
||||
};
|
||||
}, [capture, handler, ignoreSelectors, isEnabled, ref]);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user