feat: resize grid columns with drag and drop (#748)

* feat: resize grid columns with drag and drop

* fix: column resizing only in designable

* fix: batch patch

* fix: does not scroll when dragging to a blank block

* fix: overflow-x auto
This commit is contained in:
chenos 2022-08-20 18:04:14 +08:00 committed by GitHub
parent 56bd996bd4
commit 259393f626
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 241 additions and 28 deletions

View File

@ -1,3 +1,4 @@
import { css } from '@emotion/css';
import { observer, RecursionField, useFieldSchema } from '@formily/react';
import { Space } from 'antd';
import React from 'react';
@ -25,8 +26,15 @@ export const ActionBar = observer((props: any) => {
);
}
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', ...style }} {...others}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', overflowX: 'auto', ...style }}
{...others}
>
<div className={css`
.ant-space:last-child {
margin-left: 8px;
}
`} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<DndContext>
<Space>
{fieldSchema.mapProperties((schema, key) => {

View File

@ -1,14 +1,14 @@
import { useDndContext, useDndMonitor, useDroppable } from '@dnd-kit/core';
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import { css } from '@emotion/css';
import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import cls from 'classnames';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { useFormBlockContext, useSchemaInitializer } from '../../../';
import { useDesignable, useFormBlockContext, useSchemaInitializer } from '../../../';
import { DndContext } from '../../common/dnd-context';
const GridRowContext = createContext(null);
const GridColContext = createContext(null);
const GridRowContext = createContext<any>({});
const GridColContext = createContext<any>({});
const GridContext = createContext<any>({});
const breakRemoveOnGrid = (s: Schema) => s['x-component'] === 'Grid';
@ -19,6 +19,8 @@ const ColDivider = (props) => {
id: props.id,
data: props.data,
});
const { dn, designable } = useDesignable();
const dividerRef = useRef<HTMLElement>();
const droppableStyle = {
backgroundColor: isOver ? 'rgba(241, 139, 98, .1)' : undefined,
@ -38,10 +40,89 @@ const ColDivider = (props) => {
visible = activeSchema !== currentSchema && downSchema !== activeSchema;
}
}
const prevSchema = props.cols[props.index];
const nextSchema = props.cols[props.index + 1];
const {
attributes,
listeners,
setNodeRef: setDraggableNodeRef,
isDragging,
} = useDraggable({
disabled: props.first || props.last || !designable,
id: props.id,
data: {
dividerRef,
prevSchema,
nextSchema,
},
});
const [clientWidths, setClientWidths] = useState([0, 0]);
useDndMonitor({
onDragStart(event) {
if (!isDragging) {
return;
}
const el = dividerRef.current;
const prev = el.previousElementSibling as HTMLDivElement;
const next = el.nextElementSibling as HTMLDivElement;
setClientWidths([prev.clientWidth, next.clientWidth]);
},
onDragMove(event) {
if (!isDragging) {
return;
}
const el = dividerRef.current;
const prev = el.previousElementSibling as HTMLDivElement;
const next = el.nextElementSibling as HTMLDivElement;
prev.style.width = `calc(${clientWidths[0]}px + ${event.delta.x}px)`;
next.style.width = `calc(${clientWidths[1]}px - ${event.delta.x}px)`;
},
onDragEnd(event) {
if (clientWidths[0] <= 0 || clientWidths[1] <= 0) {
return;
}
setClientWidths([0, 0]);
if (!prevSchema || !nextSchema) {
return;
}
const el = dividerRef.current;
const prev = el.previousElementSibling as HTMLDivElement;
const next = el.nextElementSibling as HTMLDivElement;
prevSchema['x-component-props'] = prevSchema['x-component-props'] || {};
nextSchema['x-component-props'] = nextSchema['x-component-props'] || {};
prevSchema['x-component-props']['width'] =
(100 * (prev?.clientWidth + 24 + 24 / props.cols.length)) / el.parentElement.clientWidth;
nextSchema['x-component-props']['width'] =
(100 * (next?.clientWidth + 24 + 24 / props.cols.length)) / el.parentElement.clientWidth;
dn.emit('batchPatch', {
schemas: [
{
['x-uid']: prevSchema['x-uid'],
'x-component-props': {
...prevSchema['x-component-props'],
},
},
{
['x-uid']: nextSchema['x-uid'],
'x-component-props': {
...nextSchema['x-component-props'],
},
},
],
});
},
});
return (
<div
ref={visible ? setNodeRef : null}
ref={(el) => {
if (visible) {
setNodeRef(el);
dividerRef.current = el;
}
}}
className={cls(
'nb-col-divider',
css`
@ -49,7 +130,36 @@ const ColDivider = (props) => {
`,
)}
style={{ ...droppableStyle }}
></div>
>
<div
ref={setDraggableNodeRef}
{...listeners}
{...attributes}
className={
props.first || props.last || !designable
? null
: css`
&::before {
content: ' ';
width: 12px;
height: 100%;
left: 6px;
position: absolute;
cursor: col-resize;
}
&:hover {
&::before {
background: rgba(241, 139, 98, 0.06) !important;
}
}
width: 24px;
height: 100%;
position: absolute;
cursor: col-resize;
`
}
></div>
</div>
);
};
@ -255,7 +365,7 @@ Grid.Row = observer((props) => {
const cols = useColProperties();
return (
<GridRowContext.Provider value={{ cols }}>
<GridRowContext.Provider value={{ schema: fieldSchema, cols }}>
<div
className={cls(
'nb-grid-row',
@ -285,6 +395,7 @@ Grid.Row = observer((props) => {
<ColDivider
cols={cols}
index={index}
last={index === cols.length - 1}
id={`${addr}_${index + 1}`}
data={{
breakRemoveOn: breakRemoveOnRow,
@ -303,20 +414,33 @@ Grid.Row = observer((props) => {
Grid.Col = observer((props: any) => {
const { cols } = useContext(GridRowContext);
const w = props.width || 100 / cols.length;
const schema = useFieldSchema();
const field = useField();
const w = schema?.['x-component-props']?.['width'] || 100 / cols.length;
const width = `calc(${w}% - 24px - 24px / ${cols.length})`;
const { isOver, setNodeRef } = useDroppable({
id: field.address.toString(),
data: {
insertAdjacent: 'beforeEnd',
schema,
wrapSchema: (s) => s,
},
});
return (
<div
style={{ width }}
className={cls(
'nb-grid-col',
css`
position: relative;
/* z-index: 0; */
`,
)}
>
{props.children}
</div>
<GridColContext.Provider value={{ cols, schema }}>
<div
ref={setNodeRef}
style={{ width }}
className={cls(
'nb-grid-col',
css`
position: relative;
/* z-index: 0; */
`,
)}
>
{props.children}
</div>
</GridColContext.Provider>
);
});

View File

@ -1,7 +1,7 @@
import { DndContext as DndKitContext, DragEndEvent, DragOverlay, rectIntersection } from '@dnd-kit/core';
import { Props } from '@dnd-kit/core/dist/components/DndContext/DndContext';
import { observer } from '@formily/react';
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../';
import { createDesignable, useDesignable } from '../../hooks';
@ -18,6 +18,7 @@ const useDragEnd = (props?: any) => {
const insertAdjacent = over?.data?.current?.insertAdjacent;
const breakRemoveOn = over?.data?.current?.breakRemoveOn;
const wrapSchema = over?.data?.current?.wrapSchema;
const onSuccess = over?.data?.current?.onSuccess;
if (!activeSchema || !overSchema) {
props?.onDragEnd?.(event);
@ -49,6 +50,7 @@ const useDragEnd = (props?: any) => {
wrap: wrapSchema,
breakRemoveOn,
removeParentsIfNoChildren: true,
onSuccess,
});
props?.onDragEnd?.(event);
return;
@ -58,15 +60,25 @@ const useDragEnd = (props?: any) => {
export const DndContext = observer((props: Props) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(true);
return (
<DndKitContext collisionDetection={rectIntersection} {...props} onDragEnd={useDragEnd(props)}>
<DndKitContext
collisionDetection={rectIntersection}
{...props}
onDragStart={(event) => {
const { active } = event;
const activeSchema = active?.data?.current?.schema;
setVisible(!!activeSchema);
}}
onDragEnd={useDragEnd(props)}
>
<DragOverlay
dropAnimation={{
duration: 10,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
<span style={{ whiteSpace: 'nowrap' }}>{t('Dragging')}</span>
{visible && <span style={{ whiteSpace: 'nowrap' }}>{t('Dragging')}</span>}
</DragOverlay>
{props.children}
</DndKitContext>

View File

@ -111,7 +111,35 @@ export class Designable {
if (!api) {
return;
}
this.on('insertAdjacent', async ({ onSuccess, current, position, schema, wrap, removed }) => {
const updateColumnSize = (parent: Schema) => {
if (!parent) {
return;
}
const len = Object.values(parent.properties).length;
const schemas = [];
parent.mapProperties((s) => {
s['x-component-props'] = s['x-component-props'] || {};
s['x-component-props']['width'] = 100 / len;
if (s['x-uid']) {
schemas.push({
'x-uid': s['x-uid'],
'x-component-props': s['x-component-props'],
});
}
});
if (parent['x-uid'] && schemas.length) {
return schemas;
}
return [];
};
this.on('insertAdjacent', async ({ onSuccess, current, position, schema, wrap, wrapped, removed }) => {
let schemas = [];
if (wrapped?.['x-component'] === 'Grid.Col') {
schemas = updateColumnSize(wrapped.parent);
}
if (removed?.['x-component'] === 'Grid.Col') {
schemas = updateColumnSize(removed.parent);
}
refresh();
if (!current['x-uid']) {
return;
@ -124,6 +152,13 @@ export class Designable {
wrap,
},
});
if (schemas.length) {
await api.request({
url: `/uiSchemas:batchPatch`,
method: 'post',
data: schemas,
});
}
if (removed?.['x-uid']) {
await api.request({
url: `/uiSchemas:remove/${removed['x-uid']}`,
@ -147,7 +182,20 @@ export class Designable {
});
message.success(t('Saved successfully'), 0.2);
});
this.on('batchPatch', async ({ schemas }) => {
refresh();
await api.request({
url: `/uiSchemas:batchPatch`,
method: 'post',
data: schemas,
});
message.success(t('Saved successfully'), 0.2);
});
this.on('remove', async ({ removed }) => {
let schemas = [];
if (removed?.['x-component'] === 'Grid.Col') {
schemas = updateColumnSize(removed.parent);
}
refresh();
if (!removed?.['x-uid']) {
return;
@ -156,6 +204,13 @@ export class Designable {
url: `/uiSchemas:remove/${removed['x-uid']}`,
method: 'post',
});
if (schemas.length) {
await api.request({
url: `/uiSchemas:batchPatch`,
method: 'post',
data: schemas,
});
}
message.success(t('Saved successfully'), 0.2);
});
}
@ -177,14 +232,14 @@ export class Designable {
generateUid(schema);
}
on(name: 'insertAdjacent' | 'remove' | 'error' | 'patch', listener: any) {
on(name: 'insertAdjacent' | 'remove' | 'error' | 'patch' | 'batchPatch', listener: any) {
if (!this.events[name]) {
this.events[name] = [];
}
this.events[name].push(listener);
}
emit(name: 'insertAdjacent' | 'remove' | 'error' | 'patch', ...args) {
emit(name: 'insertAdjacent' | 'remove' | 'error' | 'patch' | 'batchPatch', ...args) {
if (!this.events[name]) {
return;
}
@ -356,6 +411,7 @@ export class Designable {
this.emit('insertAdjacent', {
position: 'beforeBegin',
schema: schema2,
wrapped,
wrap: schema1,
...opts,
});
@ -407,6 +463,7 @@ export class Designable {
position: 'afterBegin',
schema: schema2,
wrap: schema1,
wrapped,
...opts,
});
}
@ -421,6 +478,7 @@ export class Designable {
if (!Schema.isSchemaInstance(this.current)) {
return;
}
delete schema['x-index'];
const opts = { onSuccess: options.onSuccess };
const { wrap = defaultWrap, breakRemoveOn, removeParentsIfNoChildren } = options;
if (Schema.isSchemaInstance(schema)) {
@ -447,6 +505,7 @@ export class Designable {
position: 'beforeEnd',
schema: schema2,
wrap: schema1,
wrapped,
...opts,
});
}
@ -508,6 +567,7 @@ export class Designable {
position: 'afterEnd',
schema: schema2,
wrap: schema1,
wrapped,
...opts,
});
}

View File

@ -43,6 +43,7 @@ export const uiSchemaActions = {
insertNewSchema: callRepositoryMethod('insertNewSchema', 'values'),
remove: callRepositoryMethod('remove', 'resourceIndex'),
patch: callRepositoryMethod('patch', 'values'),
batchPatch: callRepositoryMethod('batchPatch', 'values'),
clearAncestor: callRepositoryMethod('clearAncestor', 'resourceIndex'),
async insertAdjacent(ctx: Context, next) {

View File

@ -303,6 +303,14 @@ export class UiSchemaRepository extends Repository {
await traverSchemaTree(newSchema);
}
@transaction()
async batchPatch(schemas: any[], options?) {
const { transaction } = options;
for (const schema of schemas) {
await this.patch(schema, { ...options, transaction });
}
}
async updateNode(uid: string, schema: any, transaction?: Transaction) {
const nodeModel = await this.findOne({
filter: {