From de2dd390e89ad968a0847852ee7aaaf17d33eee5 Mon Sep 17 00:00:00 2001 From: Jed Watson <872310+JedWatson@users.noreply.github.com> Date: Mon, 21 Aug 2023 01:28:20 +1000 Subject: [PATCH 1/6] UX improvements to the Cloud Image ComponentBlock (WIP) --- packages/keystatic/package.json | 1 + .../component-blocks/cloud-image-preview.tsx | 223 +++++++++++++----- pnpm-lock.yaml | 6 + 3 files changed, 176 insertions(+), 54 deletions(-) diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json index f4056eaf8..e582c6322 100644 --- a/packages/keystatic/package.json +++ b/packages/keystatic/package.json @@ -196,6 +196,7 @@ "@urql/exchange-persisted": "^3.0.0", "apply-ref": "^1.0.0", "cookie": "^0.5.0", + "detect-browser": "^5.3.0", "emery": "^1.4.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", diff --git a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx index 3318b589b..0377e4bc4 100644 --- a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx +++ b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx @@ -10,65 +10,189 @@ import { Icon } from '@keystar/ui/icon'; import { externalLinkIcon } from '@keystar/ui/icon/icons/externalLinkIcon'; import { useConfig } from '../app/shell/context'; -export function CloudImagePreview( - props: PreviewProps< - ObjectField - > & { - onRemove(): void; - } -) { - const src = props.fields.src.value; - const alt = props.fields.alt.value; +import { detect } from 'detect-browser'; - const dimensions = useImageDimensions(src); +const browser = detect(); +const metaSymbol = browser?.os === 'Mac OS' ? '⌘' : 'Ctrl'; +const isMobile = browser?.os === 'iOS' || browser?.os === 'Android OS'; - useEffect(() => { - if ( - dimensions && - props.fields.width.value === '' && - props.fields.height.value === '' - ) { - props.onChange({ - height: dimensions.height.toString(), - width: dimensions.width.toString(), - }); - } - }); +type ImageData = { + src: string; + width: string; + height: string; + alt: string; +}; + +function cleanImageData( + data: { + src: string; + width?: string | number; + height?: string | number; + alt?: string | number; + } = { src: '' } +): ImageData { + return { + src: data.src, + alt: 'alt' in data && typeof data.alt === 'string' ? data.alt : '', + height: 'height' in data ? getDimension(data.height) : '', + width: 'width' in data ? getDimension(data.width) : '', + }; +} + +function Placeholder({ onChange }: { onChange: (data: ImageData) => void }) { + const [isFocused, setIsFocused] = useState(false); + const [pastedData, setPastedData] = useState(cleanImageData()); + const [status, setStatus] = useState(''); const config = useConfig(); const onPaste = (event: React.ClipboardEvent) => { + event.preventDefault(); const text = event.clipboardData.getData('text/plain'); try { - const data: unknown = JSON.parse(text); + const data: any = JSON.parse(text); if ( typeof data === 'object' && data !== null && 'src' in data && typeof data.src === 'string' ) { - event.preventDefault(); - props.onChange({ - src: data.src, - alt: 'alt' in data && typeof data.alt === 'string' ? data.alt : '', - height: 'height' in data ? getDimension(data.height) : '', - width: 'width' in data ? getDimension(data.width) : '', - }); + setPastedData(cleanImageData(data)); return; } } catch (err) {} const pattern = /^\s*!\[(.*)\]\(([a-z0-9_\-/:.]+)\)\s*$/; const match = text.match(pattern); if (match) { - event.preventDefault(); - props.onChange({ - src: match[2], - alt: match[1], - height: '', - width: '', - }); + setPastedData( + cleanImageData({ + src: match[2], + alt: match[1], + }) + ); + return; + } + setPastedData(cleanImageData({ src: text })); + }; + + useEffect(() => { + if (!pastedData || !pastedData.src) { + setStatus(''); + return; + } + if (!isValidURL(pastedData.src)) { + setStatus('The pasted data is not a valid Image URL'); return; } + const img = new Image(); + setStatus('Checking pasted data...'); + img.onload = () => { + onChange({ + ...pastedData, + width: img.width.toString(), + height: img.height.toString(), + }); + }; + img.onerror = () => { + setStatus('The pasted data is not a valid Image URL'); + console.log('Invalid Image URL pasted:', pastedData); + }; + img.src = pastedData.src; + return () => { + img.onload = null; + }; + }, [pastedData, onChange]); + + return ( + + +
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onClick={() => { + document.execCommand('paste'); + }} + onPaste={onPaste} + style={{ + position: 'absolute', + cursor: 'default', + fontSize: '16px', + border: 'none', + outline: isFocused ? '2px solid #30a46c' : 'none', + background: 'transparent', + padding: 0, + paddingLeft: '2%', + margin: 0, + opacity: 0, + color: 'transparent', + borderRadius: '6px', + width: '98%', + height: '100%', + }} + /> + {isFocused ? ( + + {isMobile ? 'Tap' : `Press ${metaSymbol} + V`} to paste an Image + URL + + ) : ( + + {isMobile ? 'Tap' : 'Click'} here to paste an Image URL + + )} +
+ {config.storage.kind === 'cloud' && ( +
+ + Open Cloud Images + + +
+ )} + {status} +
+
+ ); +} + +export function CloudImagePreview( + props: PreviewProps< + ObjectField + > & { + onRemove(): void; + } +) { + const src = props.fields.src.value; + const alt = props.fields.alt.value; + + const dimensions = useImageDimensions(src); + + if (!props.fields.src.value) { + return ; + } + + const clear = () => { + props.onChange(cleanImageData()); }; return ( @@ -84,25 +208,11 @@ export function CloudImagePreview( label="Cloud Image" value={props.fields.src.value} onChange={props.fields.src.onChange} - onPaste={onPaste} flex="1" /> - {config.storage.kind === 'cloud' && - (() => { - const [team, project] = config.storage.project.split('/'); - return ( -
- - Browse - - -
- ); - })()} +
+ Change +
Remove
@@ -190,3 +300,8 @@ function getDimension(value: unknown) { if (typeof value === 'number') return value.toString(); return ''; } + +function getProjectImagesURL(projectConfig: string) { + const [team, project] = projectConfig.split('/'); + return `https://keystatic.cloud/teams/${team}/project/${project}/images`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16bdf2fa9..55c7edfda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,6 +722,7 @@ importers: '@urql/exchange-persisted': ^3.0.0 apply-ref: ^1.0.0 cookie: ^0.5.0 + detect-browser: ^5.3.0 emery: ^1.4.1 escape-string-regexp: ^4.0.0 eslint: ^8.18.0 @@ -799,6 +800,7 @@ importers: '@urql/exchange-persisted': 3.0.0_graphql@16.6.0 apply-ref: 1.0.0 cookie: 0.5.0 + detect-browser: 5.3.0 emery: 1.4.1 escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 @@ -12137,6 +12139,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + /detect-browser/5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + dev: false + /detect-indent/6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} From 8430a3b96d3b59b3ff2ecdec7824a1e1c3fe5b65 Mon Sep 17 00:00:00 2001 From: Jed Watson <872310+JedWatson@users.noreply.github.com> Date: Mon, 21 Aug 2023 23:48:04 +1000 Subject: [PATCH 2/6] New Insert Image Dialog --- packages/keystatic/package.json | 1 - .../component-blocks/cloud-image-preview.tsx | 279 ++++++++++++------ pnpm-lock.yaml | 6 - 3 files changed, 184 insertions(+), 102 deletions(-) diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json index e582c6322..f4056eaf8 100644 --- a/packages/keystatic/package.json +++ b/packages/keystatic/package.json @@ -196,7 +196,6 @@ "@urql/exchange-persisted": "^3.0.0", "apply-ref": "^1.0.0", "cookie": "^0.5.0", - "detect-browser": "^5.3.0", "emery": "^1.4.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", diff --git a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx index 0377e4bc4..30a051c59 100644 --- a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx +++ b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx @@ -2,19 +2,23 @@ import { useEffect, useState } from 'react'; import { NotEditable, ObjectField, PreviewProps } from '@keystatic/core'; -import { TextField } from '@keystar/ui/text-field'; -import { ActionButton } from '@keystar/ui/button'; -import { VStack, Flex } from '@keystar/ui/layout'; -import { Text } from '@keystar/ui/typography'; +import { TextArea, TextField } from '@keystar/ui/text-field'; +import { ActionButton, Button, ButtonGroup } from '@keystar/ui/button'; +import { VStack, Flex, Box } from '@keystar/ui/layout'; +import { Heading, Text } from '@keystar/ui/typography'; import { Icon } from '@keystar/ui/icon'; import { externalLinkIcon } from '@keystar/ui/icon/icons/externalLinkIcon'; -import { useConfig } from '../app/shell/context'; +import { imageIcon } from '@keystar/ui/icon/icons/imageIcon'; +import { trash2Icon } from '@keystar/ui/icon/icons/trash2Icon'; +import { xIcon } from '@keystar/ui/icon/icons/xIcon'; +// import { useConfig } from '../app/shell/context'; -import { detect } from 'detect-browser'; +import { ClassList, css, tokenSchema } from '@keystar/ui/style'; +import { Dialog, DialogTrigger } from '@keystar/ui/dialog'; +import { Content, Header } from '@keystar/ui/slots'; +import { ProgressCircle } from '@keystar/ui/progress'; -const browser = detect(); -const metaSymbol = browser?.os === 'Mac OS' ? '⌘' : 'Ctrl'; -const isMobile = browser?.os === 'iOS' || browser?.os === 'Android OS'; +const classList = new ClassList('ImageURLField'); type ImageData = { src: string; @@ -39,12 +43,18 @@ function cleanImageData( }; } -function Placeholder({ onChange }: { onChange: (data: ImageData) => void }) { - const [isFocused, setIsFocused] = useState(false); - const [pastedData, setPastedData] = useState(cleanImageData()); - const [status, setStatus] = useState(''); +type InsertStatus = '' | 'loading' | 'good' | 'error'; - const config = useConfig(); +function InsertImageDialog({ + onChange, + onClose, +}: { + onChange: (data: ImageData) => void; + onClose: () => void; +}) { + const [state, setState] = useState(cleanImageData()); + const [status, setStatus] = useState(''); + const imageLibraryURL = useImageLibraryURL(); const onPaste = (event: React.ClipboardEvent) => { event.preventDefault(); @@ -57,14 +67,14 @@ function Placeholder({ onChange }: { onChange: (data: ImageData) => void }) { 'src' in data && typeof data.src === 'string' ) { - setPastedData(cleanImageData(data)); + setState(cleanImageData(data)); return; } } catch (err) {} const pattern = /^\s*!\[(.*)\]\(([a-z0-9_\-/:.]+)\)\s*$/; const match = text.match(pattern); if (match) { - setPastedData( + setState( cleanImageData({ src: match[2], alt: match[1], @@ -72,105 +82,182 @@ function Placeholder({ onChange }: { onChange: (data: ImageData) => void }) { ); return; } - setPastedData(cleanImageData({ src: text })); + setState(cleanImageData({ src: text })); }; + const src = state.src; + useEffect(() => { - if (!pastedData || !pastedData.src) { + if (!src) { setStatus(''); return; } - if (!isValidURL(pastedData.src)) { - setStatus('The pasted data is not a valid Image URL'); + if (!isValidURL(src)) { return; } + setStatus('loading'); const img = new Image(); - setStatus('Checking pasted data...'); img.onload = () => { - onChange({ - ...pastedData, + setState(state => ({ + ...state, width: img.width.toString(), height: img.height.toString(), - }); + })); + setStatus('good'); }; img.onerror = () => { - setStatus('The pasted data is not a valid Image URL'); - console.log('Invalid Image URL pasted:', pastedData); + setStatus('error'); }; - img.src = pastedData.src; + img.src = src; return () => { img.onload = null; }; - }, [pastedData, onChange]); + }, [src]); return ( - - -
- setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onClick={() => { - document.execCommand('paste'); - }} + + + + Insert Cloud Image + + +
+ +
+ + { + e.preventDefault(); + if (status !== 'good') return; + onChange(state); + close(); + }} + direction="column" + gap="large" + > + + + Copy an Image URL from the Image Library and paste in the field + below to insert. + + + + +
+ ) : state.src ? ( + setState(cleanImageData())} + > + + + ) : null + } /> - {isFocused ? ( - - {isMobile ? 'Tap' : `Press ${metaSymbol} + V`} to paste an Image - URL - - ) : ( - - {isMobile ? 'Tap' : 'Click'} here to paste an Image URL - - )} - - {config.storage.kind === 'cloud' && ( -
- +