mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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:
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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user