refactor: improve copy page url (#2896)

- moved copy url to address bar
- support copy with keyboard
- persist copied state for 2 seconds
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2024-02-11 20:00:48 +07:00
1 parent b85d256645
commit ffff4e5beb
3 files changed
+104 -95

No files matched your search

@@ -1,16 +1,35 @@
import { Fragment, useId, useState } from "react";
import { compilePathnamePattern, parsePathnamePattern } from "./url-pattern";
import { Grid, InputField, Label } from "@webstudio-is/design-system";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { $dataSourceVariables } from "~/shared/nano-states";
import { Fragment, useEffect, useId, useState } from "react";
import {
Button,
Grid,
InputField,
Label,
Tooltip,
} from "@webstudio-is/design-system";
import { CheckMarkIcon, CopyIcon, LinkIcon } from "@webstudio-is/icons";
import type { DataSource } from "@webstudio-is/sdk";
import { $dataSourceVariables, $domains, $project } from "~/shared/nano-states";
import env from "~/shared/env";
import { compilePathnamePattern, parsePathnamePattern } from "./url-pattern";
const $publishedOrigin = computed([$project, $domains], (project, domains) => {
const customDomain: string | undefined = domains[0];
const projectDomain = `${project?.domain}.${
env.PUBLISHER_HOST ?? "wstd.work"
}`;
const domain = customDomain ?? projectDomain;
const publishedUrl = new URL(`https://${domain}`);
return publishedUrl.origin;
});
type PathParams = Record<string, string>;
export type AddressBarApi = {
pathParamNames: string[];
pathParams: PathParams;
compiledPath: string;
pageUrl: string;
updatePathParam: (name: string, value: string) => void;
savePathParams: () => void;
};
@@ -40,7 +59,9 @@ export const useAddressBar = ({
: (dataSourceVariables.get(dataSourceId) as Record<string, string>);
const pathParams = storedParams ?? localParams;
const publishedOrigin = useStore($publishedOrigin);
const compiledPath = compilePathnamePattern(path, pathParams);
const pageUrl = `${publishedOrigin}${compiledPath}`;
const updatePathParam: AddressBarApi["updatePathParam"] = (name, value) => {
// delete stale fields
@@ -70,14 +91,74 @@ export const useAddressBar = ({
return {
pathParamNames,
pathParams,
compiledPath,
pageUrl,
updatePathParam,
savePathParams,
};
};
const CopyPageUrl = ({ pageUrl }: { pageUrl: string }) => {
const [copyState, setCopyState] = useState<"copy" | "copied">("copy");
// reset copied state after 2 seconds
useEffect(() => {
if (copyState === "copied") {
const timeoutId = setTimeout(() => {
setCopyState("copy");
}, 2000);
return () => {
clearTimeout(timeoutId);
};
}
}, [copyState]);
let copyIcon = (
<>
<LinkIcon
style={{ display: "var(--ws-address-bar-link-icon-display, block)" }}
/>
<CopyIcon
style={{ display: "var(--ws-address-bar-copy-icon-display, none)" }}
/>
</>
);
if (copyState === "copied") {
copyIcon = <CheckMarkIcon />;
}
const parsed = new URL(pageUrl);
const pageDomainAndPath = `${parsed.host}${parsed.pathname}`;
return (
<Tooltip
// keep tooltip open when user just copied
open={copyState === "copied" ? true : undefined}
content={copyState === "copied" ? "Copied" : "Click to copy"}
>
<Button
color="ghost"
type="button"
onClick={() => {
navigator.clipboard.writeText(pageUrl);
setCopyState("copied");
}}
prefix={copyIcon}
css={{
justifySelf: "start",
"&:hover": {
"--ws-address-bar-link-icon-display": "none",
"--ws-address-bar-copy-icon-display": "block",
},
}}
>
{pageDomainAndPath}
</Button>
</Tooltip>
);
};
export const AddressBar = ({ addressBar }: { addressBar: AddressBarApi }) => {
const { pathParamNames, pathParams, updatePathParam } = addressBar;
const { pathParamNames, pathParams, pageUrl, updatePathParam } = addressBar;
const id = useId();
return (
@@ -93,6 +174,7 @@ export const AddressBar = ({ addressBar }: { addressBar: AddressBarApi }) => {
/>
</Fragment>
))}
<CopyPageUrl pageUrl={pageUrl} />
</Grid>
);
};
@@ -60,7 +60,6 @@ const MetadataItem = (props: {
<InputErrorsTooltip errors={undefined}>
<InputField
css={{ gridArea: "property-input" }}
tabIndex={1}
id={propertyId}
property="path"
value={props.property}
@@ -94,7 +93,6 @@ const MetadataItem = (props: {
}}
disabled={isLiteralExpression(props.content) === false}
color={typeof content !== "string" ? "error" : undefined}
tabIndex={1}
id={contentId}
property="path"
value={content}
@@ -41,8 +41,6 @@ import {
ChevronDoubleLeftIcon,
CopyIcon,
TrashIcon,
CheckMarkIcon,
LinkIcon,
HomeIcon,
HelpIcon,
} from "@webstudio-is/icons";
@@ -51,10 +49,8 @@ import { Header, HeaderSuffixSpacer } from "../../header";
import { updateWebstudioData } from "~/shared/instance-utils";
import {
$assets,
$domains,
$instances,
$pages,
$project,
$selectedInstanceSelector,
$dataSources,
computeExpression,
@@ -73,7 +69,6 @@ import { SocialPreview } from "./social-preview";
// @todo should be moved to shared because features should not depend on features
import { useEffectEvent } from "~/builder/features/ai/hooks/effect-event";
import { CustomMetadata } from "./custom-metadata";
import env from "~/shared/env";
import { parsePathnamePattern, validatePathnamePattern } from "./url-pattern";
import {
registerFolderChildMutable,
@@ -251,55 +246,6 @@ const toFormValues = (
const autoSelectHandler: FocusEventHandler<HTMLInputElement> = (event) =>
event.target.select();
const CopyPageDomainAndPathButton = ({
pageDomainAndPath,
}: {
pageDomainAndPath: string;
}) => {
const [pathIconState, setPathIconState] = useState<
"link" | "copy" | "checkmark"
>("link");
let pathIcon = <CopyIcon />;
if (pathIconState === "checkmark") {
pathIcon = <CheckMarkIcon />;
} else if (pathIconState === "link") {
pathIcon = <LinkIcon />;
}
return (
<Tooltip
content={pathIconState === "checkmark" ? "Copied" : "Click to copy"}
>
<Button
color="ghost"
type="button"
onPointerDown={(event) => {
navigator.clipboard.writeText(`https://${pageDomainAndPath}`);
setPathIconState("checkmark");
// Prevent tooltip to be closed
event.stopPropagation();
}}
// Recreating Icon without pointer-events: none cause mouse leave/enter event to be fired again
prefix={
<Grid align="center" css={{ pointerEvents: "none" }}>
{pathIcon}
</Grid>
}
css={{ justifySelf: "start" }}
onMouseEnter={() => {
setPathIconState("copy");
}}
onMouseLeave={() => {
setPathIconState("link");
}}
>
{pageDomainAndPath}
</Button>
</Tooltip>
);
};
const FormFields = ({
addressBar,
disabled,
@@ -336,27 +282,6 @@ const FormFields = ({
const faviconUrl = faviconAsset?.type === "image" ? faviconAsset.name : "";
const project = $project.get();
const customDomain: string | undefined = $domains.get()[0];
const projectDomain = `${project?.domain}.${
env.PUBLISHER_HOST ?? "wstd.work"
}`;
const domain = customDomain ?? projectDomain;
const publishedUrl = new URL(`https://${domain}`);
const foldersPath = getPagePath(values.parentFolderId, pages);
const pageDomainAndPath = [
publishedUrl.host,
foldersPath,
addressBar.compiledPath,
]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/");
const pageUrl = `https://${pageDomainAndPath}`;
const title = String(computeExpression(values.title, variableValues));
const description = String(
computeExpression(values.description, variableValues)
@@ -379,7 +304,6 @@ const FormFields = ({
<Label htmlFor={fieldIds.name}>Page Name</Label>
<InputErrorsTooltip errors={errors.name}>
<InputField
tabIndex={1}
color={errors.name && "error"}
id={fieldIds.name}
autoFocus
@@ -428,7 +352,6 @@ const FormFields = ({
<Grid gap={1}>
<Label htmlFor={fieldIds.parentFolderId}>Parent Folder</Label>
<Select
tabIndex={1}
css={{ zIndex: theme.zIndices[1] }}
options={pages.folders}
getValue={(folder) => folder.id}
@@ -460,7 +383,7 @@ const FormFields = ({
>
<HelpIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={0}
tabIndex={-1}
/>
</Tooltip>
)}
@@ -468,7 +391,6 @@ const FormFields = ({
</Label>
<InputErrorsTooltip errors={errors.path}>
<InputField
tabIndex={1}
color={errors.path && "error"}
id={fieldIds.path}
name="path"
@@ -483,7 +405,6 @@ const FormFields = ({
</Grid>
)}
<AddressBar addressBar={addressBar} />
<CopyPageDomainAndPathButton pageDomainAndPath={pageDomainAndPath} />
</Grid>
<Separator />
@@ -518,7 +439,7 @@ const FormFields = ({
<SearchPreview
siteName={pages?.meta?.siteName ?? ""}
faviconUrl={faviconUrl}
pageUrl={pageUrl}
pageUrl={addressBar.pageUrl}
titleLink={title}
snippet={description}
/>
@@ -554,7 +475,6 @@ const FormFields = ({
)}
<InputErrorsTooltip errors={errors.title}>
<InputField
tabIndex={1}
color={errors.title && "error"}
id={fieldIds.title}
name="title"
@@ -603,7 +523,6 @@ const FormFields = ({
)}
<InputErrorsTooltip errors={errors.description}>
<TextArea
tabIndex={1}
state={errors.description && "invalid"}
id={fieldIds.description}
name="description"
@@ -778,7 +697,7 @@ const FormFields = ({
? socialImageAsset.name
: socialImageUrl
}
ogUrl={pageUrl}
ogUrl={addressBar.pageUrl}
ogTitle={title}
ogDescription={description}
/>
@@ -852,8 +771,13 @@ export const NewPageSettings = ({
false,
variableValues
);
const foldersPath =
pages === undefined ? "" : getPagePath(values.parentFolderId, pages);
const addressBar = useAddressBar({
path: values.path,
path: [foldersPath, values.path]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/"),
});
const handleSubmit = () => {
@@ -1103,8 +1027,13 @@ export const PageSettings = ({
values.isHomePage,
variableValues
);
const foldersPath =
pages === undefined ? "" : getPagePath(values.parentFolderId, pages);
const addressBar = useAddressBar({
path: values.path,
path: [foldersPath, values.path]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/"),
dataSourceId: page?.pathVariableId,
});