From 07cbc522fdb67ad8c246dd3800556ba0ec4d54e6 Mon Sep 17 00:00:00 2001 From: kamre Date: Wed, 13 Oct 2021 18:03:12 +0700 Subject: [PATCH] [GH-1467] Automatically create new checkbox when enter is pressed (#1505) * Initial implementation for creation of new checkbox when enter is pressed. * Card detail context added: - used for adding new content element - tracks id of new block that was added - used in checkbox element to set focus * Deleting of last added empty checkbox supported. * Rename addNewElement/addNewBlock to addElement/addBlock. * New component CardDetailProvider for card detail context introduced. * Delete only automatically added checkboxes. * Fix existing unit tests: add `CardDetailProvider` when needed. * Unit tests for `CheckboxElement` updated: - use mocked mutator - test for focus of last added checkbox added - test for adding new checkbox on pressing enter added - test for deleting automatically added checkbox on pressing esc/enter added --- .../components/addContentMenuItem.test.tsx | 20 +- .../src/components/cardDetail/cardDetail.tsx | 21 +- .../cardDetail/cardDetailContents.test.tsx | 25 ++- .../cardDetailContentsMenu.test.tsx | 32 +-- .../cardDetail/cardDetailContentsMenu.tsx | 45 ++--- .../cardDetail/cardDetailContext.tsx | 81 ++++++++ .../checkboxElement.test.tsx.snap | 51 +++-- .../content/checkboxElement.test.tsx | 185 +++++++++++++----- .../components/content/checkboxElement.tsx | 42 +++- .../src/components/content/contentElement.tsx | 20 +- .../components/content/contentRegistry.tsx | 6 +- webapp/src/components/contentBlock.tsx | 1 + webapp/src/components/gallery/galleryCard.tsx | 2 + 13 files changed, 376 insertions(+), 155 deletions(-) create mode 100644 webapp/src/components/cardDetail/cardDetailContext.tsx diff --git a/webapp/src/components/addContentMenuItem.test.tsx b/webapp/src/components/addContentMenuItem.test.tsx index c13b09358..02a95d933 100644 --- a/webapp/src/components/addContentMenuItem.test.tsx +++ b/webapp/src/components/addContentMenuItem.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' +import React, {ReactElement, ReactNode} from 'react' import {render, screen, waitFor} from '@testing-library/react' import '@testing-library/jest-dom' @@ -22,9 +22,17 @@ import './content/textElement' import './content/imageElement' import './content/dividerElement' import './content/checkboxElement' +import {CardDetailProvider} from './cardDetail/cardDetailContext' const board = TestBlockFactory.createBoard() const card = TestBlockFactory.createCard(board) +const wrap = (child: ReactNode): ReactElement => ( + wrapIntl( + + {child} + , + ) +) jest.mock('../mutator') const mockedMutator = mocked(mutator, true) @@ -35,7 +43,7 @@ describe('components/addContentMenuItem', () => { }) test('return an image menu item', () => { const {container} = render( - wrapIntl( + wrap( { test('return a text menu item', async () => { const {container} = render( - wrapIntl( + wrap( { test('return a checkbox menu item', async () => { const {container} = render( - wrapIntl( + wrap( { test('return a divider menu item', async () => { const {container} = render( - wrapIntl( + wrap( { test('return an error and empty element from unknow type', () => { const {container} = render( - wrapIntl( + wrap( { {/* Content blocks */}
- - {!props.readonly && - - } + + + {!props.readonly && } +
) diff --git a/webapp/src/components/cardDetail/cardDetailContents.test.tsx b/webapp/src/components/cardDetail/cardDetailContents.test.tsx index 6883009ee..1dc76296f 100644 --- a/webapp/src/components/cardDetail/cardDetailContents.test.tsx +++ b/webapp/src/components/cardDetail/cardDetailContents.test.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' +import React, {ReactElement, ReactNode} from 'react' import {fireEvent, render} from '@testing-library/react' @@ -11,6 +11,7 @@ import {TestBlockFactory} from '../../test/testBlockFactory' import {mockDOM, wrapDNDIntl} from '../../testUtils' import CardDetailContents from './cardDetailContents' +import {CardDetailProvider} from './cardDetailContext' global.fetch = jest.fn() @@ -47,8 +48,16 @@ describe('components/cardDetail/cardDetailContents', () => { const card = TestBlockFactory.createCard(board) + const wrap = (child: ReactNode): ReactElement => ( + wrapDNDIntl( + + {child} + , + ) + ) + test('should match snapshot', async () => { - const component = wrapDNDIntl(( + const component = wrap(( { test('should match snapshot with contents array', async () => { const contents = [TestBlockFactory.createDivider(card)] - const component = wrapDNDIntl(( + const component = wrap(( { }) test('should match snapshot after onBlur triggers', async () => { - const component = wrapDNDIntl(( + const component = wrap(( { test('should match snapshot with contents array that has array inside it', async () => { const contents = [TestBlockFactory.createDivider(card), [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]] - const component = wrapDNDIntl(( + const component = wrap(( { test('should match snapshot after drag and drop event', async () => { const contents = [TestBlockFactory.createDivider(card), [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)]] card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id)) - const component = wrapDNDIntl(( + const component = wrap(( { test('should match snapshot after drag and drop event 2', async () => { const contents = [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)] card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id)) - const component = wrapDNDIntl(( + const component = wrap(( { test('should match snapshot after drag and drop event 3', async () => { const contents = [TestBlockFactory.createDivider(card), TestBlockFactory.createDivider(card)] card.fields.contentOrder = contents.map((content) => (Array.isArray(content) ? content.map((c) => c.id) : (content as any).id)) - const component = wrapDNDIntl(( + const component = wrap(( { const store = mockStateStore([], {}) + const wrap = (child: ReactNode): ReactElement => ( + wrapIntl( + + + {child} + + , + ) + ) beforeEach(() => { jest.clearAllMocks() }) test('return cardDetailContentsMenu', () => { - const {container} = render(wrapIntl( - - - , - )) + const {container} = render(wrap()) const buttonElement = screen.getByRole('button', {name: 'menuwrapper'}) userEvent.click(buttonElement) expect(container).toMatchSnapshot() }) test('return cardDetailContentsMenu and add Text content', async () => { - const {container} = render(wrapIntl( - - - , - )) + const {container} = render(wrap()) const buttonElement = screen.getByRole('button', {name: 'menuwrapper'}) userEvent.click(buttonElement) expect(container).toMatchSnapshot() - const buttonAddText = screen.getByRole('button', {name: 'text'}) - userEvent.click(buttonAddText) + await act(async () => { + const buttonAddText = screen.getByRole('button', {name: 'text'}) + userEvent.click(buttonAddText) + }) expect(container).toMatchSnapshot() }) }) diff --git a/webapp/src/components/cardDetail/cardDetailContentsMenu.tsx b/webapp/src/components/cardDetail/cardDetailContentsMenu.tsx index 43db124ae..ee11c4165 100644 --- a/webapp/src/components/cardDetail/cardDetailContentsMenu.tsx +++ b/webapp/src/components/cardDetail/cardDetailContentsMenu.tsx @@ -1,24 +1,30 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' -import {FormattedMessage, useIntl, IntlShape} from 'react-intl' +import React, {useCallback} from 'react' +import {FormattedMessage, IntlShape, useIntl} from 'react-intl' import {BlockTypes} from '../../blocks/block' -import mutator from '../../mutator' import {Utils} from '../../utils' -import {Card} from '../../blocks/card' import Button from '../../widgets/buttons/button' import Menu from '../../widgets/menu' import MenuWrapper from '../../widgets/menuWrapper' -import {ContentHandler, contentRegistry} from '../content/contentRegistry' +import {contentRegistry} from '../content/contentRegistry' -function addContentMenu(card: Card, intl: IntlShape, type: BlockTypes): JSX.Element { +import {useCardDetailContext} from './cardDetailContext' + +function addContentMenu(intl: IntlShape, type: BlockTypes): JSX.Element { const handler = contentRegistry.getHandler(type) if (!handler) { Utils.logError(`addContentMenu, unknown content type: ${type}`) return <> } + const cardDetail = useCardDetailContext() + const addElement = useCallback(async () => { + const {card} = cardDetail + const index = card.fields.contentOrder.length + cardDetail.addBlock(handler, index, false) + }, [cardDetail, handler]) return ( { - addBlock(card, intl, handler) - }} + onClick={addElement} /> ) } -async function addBlock(card: Card, intl: IntlShape, handler: ContentHandler) { - const newBlock = await handler.createBlock(card.rootId) - newBlock.parentId = card.id - newBlock.rootId = card.rootId - - const contentOrder = card.fields.contentOrder.slice() - contentOrder.push(newBlock.id) - const typeName = handler.getDisplayText(intl) - const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) - mutator.performAsUndoGroup(async () => { - await mutator.insertBlock(newBlock, description) - await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description) - }) -} - -type Props = { - card: Card -} - -const CardDetailContentsMenu = React.memo((props: Props) => { +const CardDetailContentsMenu = React.memo(() => { const intl = useIntl() return (
@@ -64,7 +49,7 @@ const CardDetailContentsMenu = React.memo((props: Props) => { /> - {contentRegistry.contentTypes.map((type) => addContentMenu(props.card, intl, type))} + {contentRegistry.contentTypes.map((type) => addContentMenu(intl, type))}
diff --git a/webapp/src/components/cardDetail/cardDetailContext.tsx b/webapp/src/components/cardDetail/cardDetailContext.tsx new file mode 100644 index 000000000..2b25bf05d --- /dev/null +++ b/webapp/src/components/cardDetail/cardDetailContext.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {createContext, ReactElement, ReactNode, useContext, useMemo, useState} from 'react' + +import {useIntl} from 'react-intl' + +import {Block} from '../../blocks/block' +import {Card} from '../../blocks/card' +import {ContentHandler} from '../content/contentRegistry' +import mutator from '../../mutator' + +export type AddedBlock = { + id: string + autoAdded: boolean +} + +export type CardDetailContextType = { + card: Card + lastAddedBlock: AddedBlock + addBlock: (handler: ContentHandler, index: number, auto: boolean) => void + deleteBlock: (block: Block, index: number) => void +} + +export const CardDetailContext = createContext(null) + +export function useCardDetailContext(): CardDetailContextType { + const cardDetailContext = useContext(CardDetailContext) + if (!cardDetailContext) { + throw new Error('CardDetailContext is not available!') + } + return cardDetailContext +} + +type CardDetailProps = { + card: Card + children: ReactNode +} + +export const CardDetailProvider = (props: CardDetailProps): ReactElement => { + const intl = useIntl() + const [lastAddedBlock, setLastAddedBlock] = useState({ + id: '', + autoAdded: false, + }) + const {card} = props + const contextValue = useMemo(() => ({ + card, + lastAddedBlock, + addBlock: async (handler: ContentHandler, index: number, auto: boolean) => { + const block = await handler.createBlock(card.rootId) + block.parentId = card.id + block.rootId = card.rootId + const contentOrder = card.fields.contentOrder.slice() + contentOrder.splice(index, 0, block.id) + setLastAddedBlock({ + id: block.id, + autoAdded: auto, + }) + const typeName = handler.getDisplayText(intl) + const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) + await mutator.performAsUndoGroup(async () => { + await mutator.insertBlock(block, description) + await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description) + }) + }, + deleteBlock: async (block: Block, index: number) => { + const contentOrder = card.fields.contentOrder.slice() + contentOrder.splice(index, 1) + const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'}) + await mutator.performAsUndoGroup(async () => { + await mutator.deleteBlock(block, description) + await mutator.changeCardContentOrder(card.id, card.fields.contentOrder, contentOrder, description) + }) + }, + }), [card, lastAddedBlock, intl]) + return ( + + {props.children} + + ) +} diff --git a/webapp/src/components/content/__snapshots__/checkboxElement.test.tsx.snap b/webapp/src/components/content/__snapshots__/checkboxElement.test.tsx.snap index c030d9920..307eb16b0 100644 --- a/webapp/src/components/content/__snapshots__/checkboxElement.test.tsx.snap +++ b/webapp/src/components/content/__snapshots__/checkboxElement.test.tsx.snap @@ -1,6 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/content/CheckboxElement should match snapshot 1`] = ` +exports[`components/content/checkboxElement should change title 1`] = ` +
+
+ + +
+
+`; + +exports[`components/content/checkboxElement should match snapshot 1`] = `
`; -exports[`components/content/CheckboxElement should match snapshot on change title 1`] = ` -
-
- - - changed name - -
-
-`; - -exports[`components/content/CheckboxElement should match snapshot on read only 1`] = ` +exports[`components/content/checkboxElement should match snapshot when read only 1`] = `
`; -exports[`components/content/CheckboxElement should match snapshot on toggle 1`] = ` +exports[`components/content/checkboxElement should toggle value 1`] = `
{ - const defaultBlock: ContentBlock = { - id: 'test-id', - workspaceId: '', - parentId: '', - rootId: '', - modifiedBy: 'test-user-id', - schema: 0, - type: 'checkbox', - title: 'test-title', - fields: {}, - createdBy: 'test-user-id', - createAt: 0, - updateAt: 0, - deleteAt: 0, - } +const board = TestBlockFactory.createBoard() +const card = TestBlockFactory.createCard(board) +const checkboxBlock: ContentBlock = { + id: 'test-id', + workspaceId: '', + parentId: card.id, + rootId: card.rootId, + modifiedBy: 'test-user-id', + schema: 1, + type: 'checkbox', + title: 'test-title', + fields: {value: false}, + createdBy: 'test-user-id', + createAt: 0, + updateAt: 0, + deleteAt: 0, +} - beforeAll(() => { - fetchMock.post('*', {}) - }) +const cardDetailContextValue = (autoAdded: boolean): CardDetailContextType => ({ + card, + lastAddedBlock: { + id: checkboxBlock.id, + autoAdded, + }, + deleteBlock: jest.fn(), + addBlock: jest.fn(), +}) - afterAll(() => { - fetchMock.mockClear() - }) +const wrap = (child: ReactNode): ReactElement => ( + wrapIntl( + + {child} + , + ) +) - test('should match snapshot', () => { - const component = wrapIntl( +describe('components/content/checkboxElement', () => { + beforeEach(jest.clearAllMocks) + + it('should match snapshot', () => { + const component = wrap( , ) @@ -49,10 +68,10 @@ describe('components/content/CheckboxElement', () => { expect(container).toMatchSnapshot() }) - test('should match snapshot on read only', () => { - const component = wrapIntl( + it('should match snapshot when read only', () => { + const component = wrap( , ) @@ -60,29 +79,101 @@ describe('components/content/CheckboxElement', () => { expect(container).toMatchSnapshot() }) - test('should match snapshot on change title', () => { - const component = wrapIntl( + it('should change title', () => { + const {container} = render(wrap( , - ) - const {container, getByTitle} = render(component) - const input = getByTitle(/test-title/i) - fireEvent.blur(input, {target: {textContent: 'changed name'}}) + )) + const newTitle = 'new title' + const input = screen.getByRole('textbox', {name: /test-title/i}) + userEvent.clear(input) + userEvent.type(input, newTitle) + fireEvent.blur(input) expect(container).toMatchSnapshot() + expect(mockedMutator.updateBlock).toHaveBeenCalledTimes(1) + expect(mockedMutator.updateBlock).toHaveBeenCalledWith( + expect.objectContaining({title: newTitle}), + checkboxBlock, + expect.anything()) }) - test('should match snapshot on toggle', () => { - const component = wrapIntl( + it('should toggle value', () => { + const {container} = render(wrap( , - ) - const {container, getByRole} = render(component) - const input = getByRole('checkbox') - fireEvent.change(input, {target: {value: 'on'}}) + )) + const input = screen.getByRole('checkbox') + userEvent.click(input) expect(container).toMatchSnapshot() + expect(mockedMutator.updateBlock).toHaveBeenCalledTimes(1) + expect(mockedMutator.updateBlock).toHaveBeenCalledWith( + expect.objectContaining({fields: {value: true}}), + checkboxBlock, + expect.anything()) + }) + + it('should have focus when last added', () => { + render(wrapIntl( + + + , + )) + const input = screen.getByRole('textbox', {name: /test-title/i}) + expect(input).toHaveFocus() + }) + + it('should add new checkbox when enter pressed', async () => { + const addElement = jest.fn() + render(wrap( + , + )) + const input = screen.getByRole('textbox', {name: /test-title/i}) + + // should not add new checkbox when current one has empty title + userEvent.clear(input) + userEvent.type(input, '{enter}') + expect(addElement).toHaveBeenCalledTimes(0) + + // should add new checkbox when current one has non-empty title + userEvent.clear(input) + userEvent.type(input, 'new-title{enter}') + await waitFor(() => expect(addElement).toHaveBeenCalledTimes(1)) + }) + + it('should delete automatically added checkbox with empty title on esc/enter pressed', () => { + const addedBlock = createContentBlock(checkboxBlock) + addedBlock.title = '' + const deleteElement = jest.fn() + + render(wrapIntl( + + + , + )) + + const input = screen.getByRole('textbox') + userEvent.type(input, '{esc}') + expect(deleteElement).toHaveBeenCalledTimes(1) + userEvent.type(input, '{enter}') + expect(deleteElement).toHaveBeenCalledTimes(2) + + // should not delete if title is not empty + userEvent.type(input, 'new-title{esc}') + expect(deleteElement).toHaveBeenCalledTimes(2) }) }) diff --git a/webapp/src/components/content/checkboxElement.tsx b/webapp/src/components/content/checkboxElement.tsx index 953160e13..29afb4044 100644 --- a/webapp/src/components/content/checkboxElement.tsx +++ b/webapp/src/components/content/checkboxElement.tsx @@ -1,25 +1,39 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState} from 'react' +import React, {useEffect, useRef, useState} from 'react' import {useIntl} from 'react-intl' import {createCheckboxBlock} from '../../blocks/checkboxBlock' import {ContentBlock} from '../../blocks/contentBlock' import CheckIcon from '../../widgets/icons/check' import mutator from '../../mutator' -import Editable from '../../widgets/editable' +import Editable, {Focusable} from '../../widgets/editable' +import {useCardDetailContext} from '../cardDetail/cardDetailContext' import {contentRegistry} from './contentRegistry' + import './checkboxElement.scss' type Props = { block: ContentBlock readonly: boolean + onAddElement?: () => void + onDeleteElement?: () => void } const CheckboxElement = React.memo((props: Props) => { const {block, readonly} = props const intl = useIntl() + const titleRef = useRef(null) + const cardDetail = useCardDetailContext() + const [addedBlockId, setAddedBlockId] = useState(cardDetail.lastAddedBlock.id) + + useEffect(() => { + if (block.id === addedBlockId) { + titleRef.current?.focus() + setAddedBlockId('') + } + }, [block, addedBlockId, titleRef]) const [active, setActive] = useState(Boolean(block.fields.value)) const [title, setTitle] = useState(block.title) @@ -42,14 +56,24 @@ const CheckboxElement = React.memo((props: Props) => { }} /> { - const newBlock = createCheckboxBlock(block) - newBlock.title = title - newBlock.fields.value = active - mutator.updateBlock(newBlock, block, intl.formatMessage({id: 'ContentBlock.editCardCheckboxText', defaultMessage: 'edit card text'})) + saveOnEsc={true} + onSave={async (saveType) => { + const {lastAddedBlock} = cardDetail + if (title === '' && block.id === lastAddedBlock.id && lastAddedBlock.autoAdded && props.onDeleteElement) { + props.onDeleteElement() + } else { + const newBlock = createCheckboxBlock(block) + newBlock.title = title + newBlock.fields.value = active + await mutator.updateBlock(newBlock, block, intl.formatMessage({id: 'ContentBlock.editCardCheckboxText', defaultMessage: 'edit card text'})) + if (saveType === 'onEnter' && title !== '' && props.onAddElement) { + props.onAddElement() + } + } }} readonly={readonly} spellCheck={true} @@ -65,11 +89,13 @@ contentRegistry.registerContentType({ createBlock: async () => { return createCheckboxBlock() }, - createComponent: (block, readonly) => { + createComponent: (block, readonly, onAddElement, onDeleteElement) => { return ( ) }, diff --git a/webapp/src/components/content/contentElement.tsx b/webapp/src/components/content/contentElement.tsx index e6ca62493..306442a04 100644 --- a/webapp/src/components/content/contentElement.tsx +++ b/webapp/src/components/content/contentElement.tsx @@ -1,9 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {useCallback} from 'react' + import {ContentBlock} from '../../blocks/contentBlock' import {Utils} from '../../utils' +import {useCardDetailContext} from '../cardDetail/cardDetailContext' + import {contentRegistry} from './contentRegistry' // Need to require here to prevent webpack from tree-shaking these away @@ -16,10 +20,12 @@ import './checkboxElement' type Props = { block: ContentBlock readonly: boolean + cords: {x: number, y?: number, z?: number} } export default function ContentElement(props: Props): JSX.Element|null { - const {block, readonly} = props + const {block, readonly, cords} = props + const cardDetail = useCardDetailContext() const handler = contentRegistry.getHandler(block.type) if (!handler) { @@ -27,5 +33,15 @@ export default function ContentElement(props: Props): JSX.Element|null { return null } - return handler.createComponent(block, readonly) + const addElement = useCallback(() => { + const index = cords.x + 1 + cardDetail.addBlock(handler, index, true) + }, [cardDetail, cords, handler]) + + const deleteElement = useCallback(() => { + const index = cords.x + cardDetail.deleteBlock(block, index) + }, [block, cords, cardDetail]) + + return handler.createComponent(block, readonly, addElement, deleteElement) } diff --git a/webapp/src/components/content/contentRegistry.tsx b/webapp/src/components/content/contentRegistry.tsx index ae81cca2b..83882fc87 100644 --- a/webapp/src/components/content/contentRegistry.tsx +++ b/webapp/src/components/content/contentRegistry.tsx @@ -7,12 +7,12 @@ import {BlockTypes} from '../../blocks/block' import {ContentBlock} from '../../blocks/contentBlock' import {Utils} from '../../utils' -type ContentHandler = { +export type ContentHandler = { type: BlockTypes, getDisplayText: (intl: IntlShape) => string, getIcon: () => JSX.Element, createBlock: (rootId: string) => Promise, - createComponent: (block: ContentBlock, readonly: boolean) => JSX.Element, + createComponent: (block: ContentBlock, readonly: boolean, onAddElement?: () => void, onDeleteElement?: () => void) => JSX.Element, } class ContentRegistry { @@ -41,6 +41,4 @@ class ContentRegistry { const contentRegistry = new ContentRegistry() -export type {ContentHandler} export {contentRegistry} - diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index c64e77e78..718d2e81e 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -149,6 +149,7 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
{ key={b.id} block={b} readonly={true} + cords={{x: 0}} /> )) } @@ -137,6 +138,7 @@ const GalleryCard = React.memo((props: Props) => { key={block.id} block={block} readonly={true} + cords={{x: 0}} /> ) })}