diff --git a/.changeset/grumpy-rocks-drop.md b/.changeset/grumpy-rocks-drop.md new file mode 100644 index 000000000..3ee2a8143 --- /dev/null +++ b/.changeset/grumpy-rocks-drop.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Allow pasting more variations of cloud image urls in `fields.cloudImage` and `cloudImage` component block diff --git a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx index 9f7d3e2d3..6c856490f 100644 --- a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx +++ b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx @@ -29,9 +29,16 @@ import { useId } from '@keystar/ui/utils'; import { useConfig } from '../app/shell/context'; import { focusWithPreviousSelection } from '../form/fields/document/DocumentEditor/ui-utils'; -import { getSplitCloudProject, isCloudConfig } from '../app/utils'; +import { + KEYSTATIC_CLOUD_API_URL, + KEYSTATIC_CLOUD_HEADERS, + getSplitCloudProject, + isCloudConfig, +} from '../app/utils'; import { NotEditable } from '../form/fields/document/DocumentEditor/primitives'; -import { PreviewProps, ObjectField } from '..'; +import { PreviewProps, ObjectField, Config } from '..'; +import { z } from 'zod'; +import { getAuth } from '../app/auth'; export type CloudImageProps = { src: string; @@ -78,6 +85,157 @@ export function parseImageData(data: string): CloudImageProps { return { src: data, alt: '' }; } +function useImageDimensions(src: string) { + const [dimensions, setDimensions] = useState({}); + useEffect(() => { + if (!src || !isValidURL(src)) { + setDimensions({}); + return; + } + let shouldSet = true; + loadImageDimensions(src).then(dimensions => { + if (shouldSet) setDimensions(dimensions); + }); + return () => { + shouldSet = false; + }; + }, [src]); + return dimensions; +} + +function loadImageDimensions(url: string) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.width, height: img.height }); + }; + img.onerror = () => { + reject(); + }; + img.src = url; + }); +} + +const imageDataSchema = z.object({ + src: z.string(), + alt: z.string(), + width: z.number(), + height: z.number(), +}); + +export async function loadImageData( + url: string, + config: Config +): Promise { + if (config.storage.kind === 'cloud') { + const auth = await getAuth(config); + if (auth) { + const res = await fetch( + `${KEYSTATIC_CLOUD_API_URL}/v1/image?${new URLSearchParams({ url })}`, + { + headers: { + Authorization: `Bearer ${auth!.accessToken}`, + ...KEYSTATIC_CLOUD_HEADERS, + }, + } + ); + if (res.ok) { + const data = await res.json(); + const parsed = imageDataSchema.safeParse(data); + if (parsed.success) { + return parsed.data; + } + } + } + } + return loadImageDimensions(url).then(dimensions => ({ + src: url, + alt: '', + ...dimensions, + })); +} + +export function ImageDimensionsInput(props: { + src: string; + image: ImageDimensions; + onChange: (image: ImageDimensions) => void; +}) { + const dimensions = useImageDimensions(props.src); + + const [constrainProportions, setConstrainProportions] = useState(true); + const revertLabel = `Revert to original (${dimensions.width} × ${dimensions.height})`; + const dimensionsMatchOriginal = + dimensions.width === props.image.width && + dimensions.height === props.image.height; + + return ( + + { + if (constrainProportions) { + props.onChange({ + width, + height: Math.round(width / getAspectRatio(props.image)), + }); + } else { + props.onChange({ width }); + } + }} + /> + + { + setConstrainProportions(state => !state); + }} + > + + + Constrain proportions + + { + if (constrainProportions) { + props.onChange({ + height, + width: Math.round(height * getAspectRatio(props.image)), + }); + } else { + props.onChange({ height }); + } + }} + /> + + { + props.onChange({ + height: dimensions.height, + width: dimensions.width, + }); + }} + > + + + {revertLabel} + + + ); +} + export const emptyImageData: CloudImageProps = { src: '', alt: '' }; type ImageStatus = '' | 'loading' | 'good' | 'error'; @@ -91,20 +249,17 @@ function ImageDialog(props: { const { image, onCancel, onChange, onClose } = props; const [state, setState] = useState(image ?? emptyImageData); const [status, setStatus] = useState(image ? 'good' : ''); - const [constrainProportions, setConstrainProportions] = useState(true); - const [dimensions, setDimensions] = useState(emptyImageData); const formId = useId(); const imageLibraryURL = useImageLibraryURL(); - const revertLabel = `Revert to original (${dimensions.width} × ${dimensions.height})`; - const dimensionsMatchOriginal = - dimensions.width === state.width && dimensions.height === state.height; - const onPaste = (event: React.ClipboardEvent) => { event.preventDefault(); const text = event.clipboardData.getData('text/plain'); setState(parseImageData(text)); }; + const config = useConfig(); + + const hasSetFields = !!(state.alt || state.width || state.height); useEffect(() => { if (!state.src) { @@ -114,22 +269,20 @@ function ImageDialog(props: { if (!isValidURL(state.src)) { return; } - setStatus('loading'); - const img = new Image(); - img.onload = () => { - const dimensions = { width: img.width, height: img.height }; - setState(state => ({ ...state, ...dimensions })); - setDimensions(dimensions); + if (hasSetFields) { setStatus('good'); - }; - img.onerror = () => { - setStatus('error'); - }; - img.src = state.src; - return () => { - img.onload = null; - }; - }, [state.src]); + return; + } + setStatus('loading'); + loadImageData(state.src, config) + .then(newData => { + setState(state => ({ ...state, ...newData })); + setStatus('good'); + }) + .catch(() => { + setStatus('error'); + }); + }, [config, hasSetFields, state.src]); return ( @@ -201,73 +354,13 @@ function ImageDialog(props: { value={state.alt} onChange={alt => setState(state => ({ ...state, alt }))} /> - - { - if (constrainProportions) { - setState(state => ({ - ...state, - width, - height: Math.round(width / getAspectRatio(state)), - })); - } else { - setState(state => ({ ...state, width })); - } - }} - /> - - { - setConstrainProportions(state => !state); - }} - > - - - Constrain proportions - - { - if (constrainProportions) { - setState(state => ({ - ...state, - height, - width: Math.round(height * getAspectRatio(state)), - })); - } else { - setState(state => ({ ...state, height })); - } - }} - /> - - { - setState(state => ({ - ...state, - height: dimensions.height, - width: dimensions.width, - })); - }} - > - - - {revertLabel} - - + { + setState(state => ({ ...state, ...dimensions })); + }} + /> ) : null} @@ -483,7 +576,7 @@ export function useImageLibraryURL() { return `https://keystatic.cloud/teams/${team}/project/${project}/images`; } -function getAspectRatio(state: CloudImageProps) { +function getAspectRatio(state: ImageDimensions) { if (!state.width || !state.height) return 1; return state.width / state.height; } diff --git a/packages/keystatic/src/form/fields/cloudImage/ui.tsx b/packages/keystatic/src/form/fields/cloudImage/ui.tsx index 37194909d..4beac065f 100644 --- a/packages/keystatic/src/form/fields/cloudImage/ui.tsx +++ b/packages/keystatic/src/form/fields/cloudImage/ui.tsx @@ -1,17 +1,11 @@ import { useEffect, useId, useState } from 'react'; -import { ActionButton, ClearButton, ToggleButton } from '@keystar/ui/button'; +import { ClearButton } from '@keystar/ui/button'; import { ObjectField, PreviewProps } from '@keystatic/core'; -import { Icon } from '@keystar/ui/icon'; -import { link2Icon } from '@keystar/ui/icon/icons/link2Icon'; -import { link2OffIcon } from '@keystar/ui/icon/icons/link2OffIcon'; -import { undo2Icon } from '@keystar/ui/icon/icons/undo2Icon'; -import { Box, Flex, HStack, VStack } from '@keystar/ui/layout'; +import { Box, Flex, VStack } from '@keystar/ui/layout'; import { TextLink } from '@keystar/ui/link'; -import { NumberField } from '@keystar/ui/number-field'; import { ProgressCircle } from '@keystar/ui/progress'; import { TextArea, TextField } from '@keystar/ui/text-field'; -import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'; import { Text } from '@keystar/ui/typography'; import { cloudImageSchema } from '../../../component-blocks/cloud-image-schema'; import { @@ -19,34 +13,15 @@ import { parseImageData, useImageLibraryURL, CloudImageProps, + ImageDimensionsInput, + loadImageData, } from '../../../component-blocks/cloud-image-preview'; import { isValidURL } from '../document/DocumentEditor/isValidURL'; import { useEventCallback } from '../document/DocumentEditor/ui-utils'; - -type ImageDimensions = Pick; +import { useConfig } from '../../../app/shell/context'; type ImageStatus = '' | 'loading' | 'good' | 'error'; -function useImageDimensions(src: string) { - const [dimensions, setDimensions] = useState({}); - useEffect(() => { - if (!src || !isValidURL(src)) { - setDimensions({}); - return; - } - const img = new Image(); - img.onload = () => { - setDimensions({ width: img.width, height: img.height }); - }; - img.src = src; - return () => { - img.onload = null; - img.onerror = null; - }; - }, [src]); - return dimensions; -} - function ImageField(props: { image: CloudImageProps; isRequired?: boolean; @@ -57,49 +32,47 @@ function ImageField(props: { const { image, onChange } = props; const [status, setStatus] = useState(image.src ? 'good' : ''); const imageLibraryURL = useImageLibraryURL(); - const dimensions = useImageDimensions(image.src); - const [constrainProportions, setConstrainProportions] = useState(true); - const revertLabel = `Revert to original (${dimensions.width} × ${dimensions.height})`; - const dimensionsMatchOriginal = - dimensions.width === image.width && dimensions.height === image.height; - - const [lastPastedUrl, setLastPastedUrl] = useState(null); const onPaste = (event: React.ClipboardEvent) => { event.preventDefault(); const text = event.clipboardData.getData('text/plain'); const parsed = parseImageData(text); - setLastPastedUrl(parsed.src); props.onChange(parsed); }; - const onLoad = useEventCallback((img: HTMLImageElement) => { - const dimensions = { width: img.width, height: img.height }; - onChange({ ...image, ...dimensions }); + const onLoad = useEventCallback(data => { + onChange(data); setStatus('good'); }); - const urlForAutoDimensions = lastPastedUrl === image.src ? lastPastedUrl : ''; + const config = useConfig(); + + const hasSetFields = !!( + props.image.alt || + props.image.width || + props.image.height + ); useEffect(() => { - if (!urlForAutoDimensions) return; - if (!isValidURL(urlForAutoDimensions)) { + if (!props.image.src) { setStatus(''); return; } + if (!isValidURL(props.image.src)) { + return; + } + if (hasSetFields) { + setStatus('good'); + return; + } setStatus('loading'); - const img = new Image(); - img.onload = () => onLoad(img); - img.onerror = () => { - setStatus('error'); - }; - img.src = urlForAutoDimensions; - return () => { - img.onload = null; - img.onerror = null; - setStatus(''); - }; - }, [urlForAutoDimensions, onLoad]); - + loadImageData(props.image.src, config) + .then(newData => { + onLoad(newData); + }) + .catch(() => { + setStatus('error'); + }); + }, [config, hasSetFields, onLoad, props.image.src]); const [blurred, setBlurred] = useState(false); const errorMessage = @@ -174,75 +147,13 @@ function ImageField(props: { value={image.alt} onChange={alt => props.onChange({ ...image, alt })} /> - - { - if (constrainProportions) { - props.onChange({ - ...image, - width, - height: Math.round(width / getAspectRatio(image)), - }); - } else { - props.onChange({ ...image, width }); - } - }} - /> - - { - setConstrainProportions(state => !state); - }} - > - - - Constrain proportions - - { - if (constrainProportions) { - props.onChange({ - ...image, - height, - width: Math.round(height * getAspectRatio(image)), - }); - } else { - props.onChange({ ...image, height }); - } - }} - /> - - { - props.onChange({ - ...image, - height: dimensions.height, - width: dimensions.width, - }); - }} - > - - - {revertLabel} - - + { + onChange({ ...props.image, ...dimensions }); + }} + /> ) : null} @@ -297,8 +208,3 @@ export function CloudImageFieldInput( ); } - -function getAspectRatio(state: CloudImageProps) { - if (!state.width || !state.height) return 1; - return state.width / state.height; -}