-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix layout and component block floating toolbars being shown behind o…
…ther elements
- Loading branch information
Showing
11 changed files
with
641 additions
and
538 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@keystone-6/fields-document': patch | ||
--- | ||
|
||
Fixed layout and component block floating toolbars being shown behind other elements |
192 changes: 192 additions & 0 deletions
192
packages/fields-document/src/DocumentEditor/component-blocks/chromeful-element.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,192 @@ | ||
/** @jsxRuntime classic */ | ||
/** @jsx jsx */ | ||
import { jsx, useTheme } from '@keystone-ui/core'; | ||
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon'; | ||
import { Tooltip } from '@keystone-ui/tooltip'; | ||
import { ReactNode, useMemo, useState, useCallback, Fragment } from 'react'; | ||
import { RenderElementProps } from 'slate-react'; | ||
import { Stack } from '@keystone-ui/core'; | ||
import { Button as KeystoneUIButton } from '@keystone-ui/button'; | ||
import { ToolbarGroup, ToolbarButton, ToolbarSeparator } from '../primitives'; | ||
import { | ||
PreviewPropsForToolbar, | ||
ObjectField, | ||
ComponentSchema, | ||
ComponentBlock, | ||
NotEditable, | ||
} from './api'; | ||
import { clientSideValidateProp } from './utils'; | ||
import { GenericPreviewProps } from './api'; | ||
import { | ||
FormValueContentFromPreviewProps, | ||
NonChildFieldComponentSchema, | ||
} from './form-from-preview'; | ||
|
||
export function ChromefulComponentBlockElement(props: { | ||
children: ReactNode; | ||
renderedBlock: ReactNode; | ||
componentBlock: ComponentBlock & { chromeless?: false }; | ||
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>; | ||
elementProps: Record<string, unknown>; | ||
onRemove: () => void; | ||
attributes: RenderElementProps['attributes']; | ||
}) { | ||
const { colors, fields, spacing, typography } = useTheme(); | ||
|
||
const isValid = useMemo( | ||
() => | ||
clientSideValidateProp( | ||
{ kind: 'object', fields: props.componentBlock.schema }, | ||
props.elementProps | ||
), | ||
|
||
[props.componentBlock, props.elementProps] | ||
); | ||
|
||
const [editMode, setEditMode] = useState(false); | ||
const onCloseEditMode = useCallback(() => { | ||
setEditMode(false); | ||
}, []); | ||
const onShowEditMode = useCallback(() => { | ||
setEditMode(true); | ||
}, []); | ||
|
||
const ChromefulToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithChrome; | ||
return ( | ||
<div | ||
{...props.attributes} | ||
css={{ | ||
marginBottom: spacing.xlarge, | ||
marginTop: spacing.xlarge, | ||
paddingLeft: spacing.xlarge, | ||
position: 'relative', | ||
':before': { | ||
content: '" "', | ||
backgroundColor: editMode ? colors.linkColor : colors.border, | ||
borderRadius: 4, | ||
width: 4, | ||
position: 'absolute', | ||
left: 0, | ||
top: 0, | ||
bottom: 0, | ||
zIndex: 1, | ||
}, | ||
}} | ||
> | ||
<NotEditable | ||
css={{ | ||
color: fields.legendColor, | ||
display: 'block', | ||
fontSize: typography.fontSize.small, | ||
fontWeight: typography.fontWeight.bold, | ||
lineHeight: 1, | ||
marginBottom: spacing.small, | ||
textTransform: 'uppercase', | ||
}} | ||
> | ||
{props.componentBlock.label} | ||
</NotEditable> | ||
{editMode ? ( | ||
<Fragment> | ||
<FormValue isValid={isValid} props={props.previewProps} onClose={onCloseEditMode} /> | ||
<div css={{ display: 'none' }}>{props.children}</div> | ||
</Fragment> | ||
) : ( | ||
<Fragment> | ||
{props.children} | ||
<ChromefulToolbar | ||
isValid={isValid} | ||
onRemove={props.onRemove} | ||
onShowEditMode={onShowEditMode} | ||
props={props.previewProps} | ||
/> | ||
</Fragment> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
function DefaultToolbarWithChrome({ | ||
onShowEditMode, | ||
onRemove, | ||
isValid, | ||
}: { | ||
onShowEditMode(): void; | ||
onRemove(): void; | ||
props: any; | ||
isValid: boolean; | ||
}) { | ||
const theme = useTheme(); | ||
return ( | ||
<ToolbarGroup as={NotEditable} marginTop="small"> | ||
<ToolbarButton | ||
onClick={() => { | ||
onShowEditMode(); | ||
}} | ||
> | ||
Edit | ||
</ToolbarButton> | ||
<ToolbarSeparator /> | ||
<Tooltip content="Remove" weight="subtle"> | ||
{attrs => ( | ||
<ToolbarButton | ||
variant="destructive" | ||
onClick={() => { | ||
onRemove(); | ||
}} | ||
{...attrs} | ||
> | ||
<Trash2Icon size="small" /> | ||
</ToolbarButton> | ||
)} | ||
</Tooltip> | ||
{!isValid && ( | ||
<Fragment> | ||
<ToolbarSeparator /> | ||
<span | ||
css={{ | ||
color: theme.palette.red500, | ||
display: 'flex', | ||
alignItems: 'center', | ||
paddingLeft: theme.spacing.small, | ||
}} | ||
> | ||
Please edit the form, there are invalid fields. | ||
</span> | ||
</Fragment> | ||
)} | ||
</ToolbarGroup> | ||
); | ||
} | ||
|
||
function FormValue({ | ||
onClose, | ||
props, | ||
isValid, | ||
}: { | ||
props: GenericPreviewProps<NonChildFieldComponentSchema, unknown>; | ||
onClose(): void; | ||
isValid: boolean; | ||
}) { | ||
const [forceValidation, setForceValidation] = useState(false); | ||
|
||
return ( | ||
<Stack gap="xlarge" contentEditable={false}> | ||
<FormValueContentFromPreviewProps {...props} forceValidation={forceValidation} /> | ||
<KeystoneUIButton | ||
size="small" | ||
tone="active" | ||
weight="bold" | ||
onClick={() => { | ||
if (isValid) { | ||
onClose(); | ||
} else { | ||
setForceValidation(true); | ||
} | ||
}} | ||
> | ||
Done | ||
</KeystoneUIButton> | ||
</Stack> | ||
); | ||
} |
68 changes: 68 additions & 0 deletions
68
packages/fields-document/src/DocumentEditor/component-blocks/chromeless-element.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
/** @jsxRuntime classic */ | ||
/** @jsx jsx */ | ||
import { jsx, useTheme } from '@keystone-ui/core'; | ||
import { Trash2Icon } from '@keystone-ui/icons/icons/Trash2Icon'; | ||
import { useControlledPopover } from '@keystone-ui/popover'; | ||
import { Tooltip } from '@keystone-ui/tooltip'; | ||
import { ReactNode } from 'react'; | ||
import { RenderElementProps } from 'slate-react'; | ||
import { InlineDialog, ToolbarButton } from '../primitives'; | ||
import { ComponentBlock, PreviewPropsForToolbar, ObjectField, ComponentSchema } from './api'; | ||
|
||
export function ChromelessComponentBlockElement(props: { | ||
renderedBlock: ReactNode; | ||
componentBlock: ComponentBlock & { chromeless: true }; | ||
previewProps: PreviewPropsForToolbar<ObjectField<Record<string, ComponentSchema>>>; | ||
isOpen: boolean; | ||
onRemove: () => void; | ||
attributes: RenderElementProps['attributes']; | ||
}) { | ||
const { trigger, dialog } = useControlledPopover( | ||
{ isOpen: props.isOpen, onClose: () => {} }, | ||
{ modifiers: [{ name: 'offset', options: { offset: [0, 8] } }] } | ||
); | ||
const { spacing } = useTheme(); | ||
const ChromelessToolbar = props.componentBlock.toolbar ?? DefaultToolbarWithoutChrome; | ||
return ( | ||
<div | ||
{...props.attributes} | ||
css={{ | ||
marginBottom: spacing.xlarge, | ||
marginTop: spacing.xlarge, | ||
}} | ||
> | ||
<div {...trigger.props} ref={trigger.ref}> | ||
{props.renderedBlock} | ||
{props.isOpen && ( | ||
<InlineDialog {...dialog.props} ref={dialog.ref}> | ||
<ChromelessToolbar onRemove={props.onRemove} props={props.previewProps} /> | ||
</InlineDialog> | ||
)} | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
function DefaultToolbarWithoutChrome({ | ||
onRemove, | ||
}: { | ||
onRemove(): void; | ||
props: Record<string, any>; | ||
}) { | ||
return ( | ||
<Tooltip content="Remove" weight="subtle"> | ||
{attrs => ( | ||
<ToolbarButton | ||
variant="destructive" | ||
onMouseDown={event => { | ||
event.preventDefault(); | ||
onRemove(); | ||
}} | ||
{...attrs} | ||
> | ||
<Trash2Icon size="small" /> | ||
</ToolbarButton> | ||
)} | ||
</Tooltip> | ||
); | ||
} |
89 changes: 89 additions & 0 deletions
89
packages/fields-document/src/DocumentEditor/component-blocks/component-block-render.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/** @jsxRuntime classic */ | ||
/** @jsx jsx */ | ||
import { jsx } from '@keystone-ui/core'; | ||
import React, { useContext } from 'react'; | ||
import { useMemo, ReactElement } from 'react'; | ||
import { Element } from 'slate'; | ||
import { ComponentBlock } from './api'; | ||
import { createGetPreviewProps, getKeysForArrayValue } from './preview-props'; | ||
import { ReadonlyPropPath } from './utils'; | ||
|
||
export const ChildrenByPathContext = React.createContext<Record<string, ReactElement>>({}); | ||
|
||
export function ChildFieldEditable({ path }: { path: readonly string[] }) { | ||
const childrenByPath = useContext(ChildrenByPathContext); | ||
const child = childrenByPath[JSON.stringify(path)]; | ||
if (child === undefined) { | ||
return null; | ||
} | ||
return child; | ||
} | ||
|
||
export function ComponentBlockRender({ | ||
componentBlock, | ||
element, | ||
onChange, | ||
children, | ||
}: { | ||
element: Element & { type: 'component-block' }; | ||
onChange: (cb: (props: Record<string, unknown>) => Record<string, unknown>) => void; | ||
componentBlock: ComponentBlock; | ||
children: any; | ||
}) { | ||
const getPreviewProps = useMemo(() => { | ||
return createGetPreviewProps( | ||
{ kind: 'object', fields: componentBlock.schema }, | ||
onChange, | ||
path => <ChildFieldEditable path={path} /> | ||
); | ||
}, [onChange, componentBlock]); | ||
|
||
const previewProps = getPreviewProps(element.props); | ||
|
||
const childrenByPath: Record<string, ReactElement> = {}; | ||
let maybeChild: ReactElement | undefined; | ||
children.forEach((child: ReactElement) => { | ||
const propPath = child.props.children.props.element.propPath; | ||
if (propPath === undefined) { | ||
maybeChild = child; | ||
} else { | ||
childrenByPath[JSON.stringify(propPathWithIndiciesToKeys(propPath, element.props))] = child; | ||
} | ||
}); | ||
|
||
const ComponentBlockPreview = componentBlock.preview; | ||
|
||
return ( | ||
<ChildrenByPathContext.Provider value={childrenByPath}> | ||
{useMemo( | ||
() => ( | ||
<ComponentBlockPreview {...previewProps} /> | ||
), | ||
[previewProps, ComponentBlockPreview] | ||
)} | ||
<span css={{ display: 'none' }}>{maybeChild}</span> | ||
</ChildrenByPathContext.Provider> | ||
); | ||
} | ||
|
||
// note this is written to avoid crashing when the given prop path doesn't exist in the value | ||
// this is because editor updates happen asynchronously but we have some logic to ensure | ||
// that updating the props of a component block synchronously updates it | ||
// (this is primarily to not mess up things like cursors in inputs) | ||
// this means that sometimes the child elements will be inconsistent with the values | ||
// so to deal with this, we return a prop path this is "wrong" but won't break anything | ||
function propPathWithIndiciesToKeys(propPath: ReadonlyPropPath, val: any): readonly string[] { | ||
return propPath.map(key => { | ||
if (typeof key === 'string') { | ||
val = val?.[key]; | ||
return key; | ||
} | ||
if (!Array.isArray(val)) { | ||
val = undefined; | ||
return ''; | ||
} | ||
const keys = getKeysForArrayValue(val); | ||
val = val?.[key]; | ||
return keys[key]; | ||
}); | ||
} |
40 changes: 0 additions & 40 deletions
40
packages/fields-document/src/DocumentEditor/component-blocks/edit-mode.tsx
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.