diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index 622a6d732..2c9a5cf24 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -8,7 +8,7 @@ interface Card extends IBlock { readonly icon: string readonly isTemplate: boolean readonly properties: Readonly> - readonly contentOrder: readonly string[] + readonly contentOrder: Readonly> duplicate(): MutableCard } @@ -35,10 +35,10 @@ class MutableCard extends MutableBlock implements Card { this.fields.properties = value } - get contentOrder(): string[] { + get contentOrder(): Array { return this.fields.contentOrder } - set contentOrder(value: string[]) { + set contentOrder(value: Array) { this.fields.contentOrder = value } diff --git a/webapp/src/blocks/contentBlock.ts b/webapp/src/blocks/contentBlock.ts index 0cbe78c0e..5f8ebc977 100644 --- a/webapp/src/blocks/contentBlock.ts +++ b/webapp/src/blocks/contentBlock.ts @@ -3,8 +3,9 @@ import {IBlock, MutableBlock} from './block' type IContentBlock = IBlock +type IContentBlockWithCords = {block: IBlock, cords: {x: number, y?: number, z?: number}} class MutableContentBlock extends MutableBlock implements IContentBlock { } -export {IContentBlock, MutableContentBlock} +export {IContentBlock, IContentBlockWithCords, MutableContentBlock} diff --git a/webapp/src/components/addContentMenuItem.tsx b/webapp/src/components/addContentMenuItem.tsx index b225f6799..9ae352483 100644 --- a/webapp/src/components/addContentMenuItem.tsx +++ b/webapp/src/components/addContentMenuItem.tsx @@ -6,7 +6,6 @@ import {useIntl} from 'react-intl' import {BlockTypes} from '../blocks/block' import {Card} from '../blocks/card' -import {IContentBlock} from '../blocks/contentBlock' import mutator from '../mutator' import {Utils} from '../utils' import Menu from '../widgets/menu' @@ -15,14 +14,14 @@ import {contentRegistry} from './content/contentRegistry' type Props = { type: BlockTypes - block: IContentBlock card: Card - contents: readonly IContentBlock[] + cords: {x: number, y?: number, z?: number} } const AddContentMenuItem = React.memo((props:Props): JSX.Element => { - const {card, contents, block, type} = props - const index = contents.indexOf(block) + const {card, type, cords} = props + const index = cords.x + const contentOrder = card.contentOrder.slice() const intl = useIntl() const handler = contentRegistry.getHandler(type) @@ -42,7 +41,6 @@ const AddContentMenuItem = React.memo((props:Props): JSX.Element => { newBlock.parentId = card.id newBlock.rootId = card.rootId - const contentOrder = contents.map((o) => o.id) contentOrder.splice(index, 0, newBlock.id) const typeName = handler.getDisplayText(intl) const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName}) diff --git a/webapp/src/components/cardDetail/cardDetailContents.tsx b/webapp/src/components/cardDetail/cardDetailContents.tsx index 51cbd0929..d28ea9f2b 100644 --- a/webapp/src/components/cardDetail/cardDetailContents.tsx +++ b/webapp/src/components/cardDetail/cardDetailContents.tsx @@ -3,15 +3,18 @@ import React from 'react' import {useIntl, IntlShape} from 'react-intl' -import {IContentBlock} from '../../blocks/contentBlock' +import {IContentBlockWithCords, IContentBlock} from '../../blocks/contentBlock' import {MutableTextBlock} from '../../blocks/textBlock' import mutator from '../../mutator' import {CardTree} from '../../viewModel/cardTree' import {Card} from '../../blocks/card' +import {useSortableWithGrip} from '../../hooks/sortable' import ContentBlock from '../contentBlock' import {MarkdownEditor} from '../markdownEditor' +export type Position = 'left' | 'right' | 'above' | 'below' | 'aboveRow' | 'belowRow' + type Props = { cardTree: CardTree readonly: boolean @@ -32,15 +35,62 @@ function addTextBlock(card: Card, intl: IntlShape, text: string): void { }) } -function moveBlock(card: Card, srcBlock: IContentBlock, dstBlock: IContentBlock, intl: IntlShape): void { - let contentOrder = card.contentOrder.slice() - const isDraggingDown = contentOrder.indexOf(srcBlock.id) <= contentOrder.indexOf(dstBlock.id) - contentOrder = contentOrder.filter((id) => srcBlock.id !== id) - let destIndex = contentOrder.indexOf(dstBlock.id) - if (isDraggingDown) { - destIndex += 1 +function moveBlock(card: Card, srcBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, intl: IntlShape, moveTo: Position): void { + const contentOrder = card.contentOrder.slice() + + const srcBlockId = srcBlock.block.id + const dstBlockId = dstBlock.block.id + + const srcBlockX = srcBlock.cords.x + let dstBlockX = dstBlock.cords.x + + const srcBlockY = (srcBlock.cords.y || srcBlock.cords.y === 0) && (srcBlock.cords.y > -1) ? srcBlock.cords.y : -1 + let dstBlockY = (dstBlock.cords.y || dstBlock.cords.y === 0) && (dstBlock.cords.y > -1) ? dstBlock.cords.y : -1 + + if (srcBlockId === dstBlockId) { + return + } + + // Delete Src Block + if (srcBlockY > -1) { + (contentOrder[srcBlockX] as string[]).splice(srcBlockY, 1) + + if (contentOrder[srcBlockX].length === 1) { + contentOrder.splice(srcBlockX, 1, contentOrder[srcBlockX][0]) + } + } else { + contentOrder.splice(srcBlockX, 1) + + if (dstBlockX > srcBlockX) { + dstBlockX -= 1 + } + } + + if (moveTo === 'right') { + if (dstBlockY > -1) { + if (dstBlockX === srcBlockX && dstBlockY > srcBlockY) { + dstBlockY -= 1 + } + + (contentOrder[dstBlockX] as string[]).splice(dstBlockY + 1, 0, srcBlockId) + } else { + contentOrder.splice(dstBlockX, 1, [dstBlockId, srcBlockId]) + } + } else if (moveTo === 'left') { + if (dstBlockY > -1) { + if (dstBlockX === srcBlockX && dstBlockY > srcBlockY) { + dstBlockY -= 1 + } + + (contentOrder[dstBlockX] as string[]).splice(dstBlockY, 0, srcBlockId) + } else { + contentOrder.splice(dstBlockX, 1, [srcBlockId, dstBlockId]) + } + } else if (moveTo === 'aboveRow') { + contentOrder.splice(dstBlockX, 0, srcBlockId) + } else if (moveTo === 'belowRow') { + contentOrder.splice(dstBlockX + 1, 0, srcBlockId) } - contentOrder.splice(destIndex, 0, srcBlock.id) mutator.performAsUndoGroup(async () => { const description = intl.formatMessage({id: 'CardDetail.moveContent', defaultMessage: 'move card content'}) @@ -48,6 +98,82 @@ function moveBlock(card: Card, srcBlock: IContentBlock, dstBlock: IContentBlock, }) } +type ContentBlockWithDragAndDropProps = { + block: IContentBlock | IContentBlock[], + x: number, + card: Card, + cardTree: CardTree, + intl: IntlShape, + readonly: boolean, +} + +const ContentBlockWithDragAndDrop = (props: ContentBlockWithDragAndDropProps) => { + const [, isOver,, itemRef] = useSortableWithGrip('content', {block: props.block, cords: {x: props.x}}, true, (src, dst) => moveBlock(props.card, src, dst, props.intl, 'aboveRow')) + const [, isOver2,, itemRef2] = useSortableWithGrip('content', {block: props.block, cords: {x: props.x}}, true, (src, dst) => moveBlock(props.card, src, dst, props.intl, 'belowRow')) + + if (Array.isArray(props.block)) { + return ( +
+
+
+ + {props.block.map((b, y) => ( + moveBlock(props.card, src, dst, props.intl, moveTo)} + cords={{x: props.x, y}} + /> + ))} +
+ {props.x === props.cardTree.contents.length - 1 && ( +
+ )} +
+ + ) + } + + return ( +
+
+ moveBlock(props.card, src, dst, props.intl, moveTo)} + cords={{x: props.x}} + /> + {props.x === props.cardTree.contents.length - 1 && ( +
+ )} +
+ + ) +} + const CardDetailContents = React.memo((props: Props) => { const intl = useIntl() const {cardTree} = props @@ -55,20 +181,22 @@ const CardDetailContents = React.memo((props: Props) => { return null } const {card} = cardTree - if (cardTree.contents.length > 0) { return (
- {cardTree.contents.map((block) => ( - moveBlock(card, src, dst, intl)} - /> - ))} + {cardTree.contents.map((block, x) => + ( + + ), + )}
) } diff --git a/webapp/src/components/contentBlock.scss b/webapp/src/components/contentBlock.scss index b1d89fc41..409c1389b 100644 --- a/webapp/src/components/contentBlock.scss +++ b/webapp/src/components/contentBlock.scss @@ -15,6 +15,7 @@ } > * { flex: 1 1 auto; + max-width: 100%; } > .octo-block-margin { flex: 0 0 auto; @@ -23,3 +24,12 @@ pointer-events: none; } } + +.rowContents { + display: flex; + width: 100%; +} + +.addToRow { + width: 10px; +} \ No newline at end of file diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index f8d19c1cf..6686b0913 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -5,7 +5,7 @@ import React from 'react' import {useIntl} from 'react-intl' import {Card} from '../blocks/card' -import {IContentBlock} from '../blocks/contentBlock' +import {IContentBlock, IContentBlockWithCords} from '../blocks/contentBlock' import mutator from '../mutator' import {Utils} from '../utils' import IconButton from '../widgets/buttons/iconButton' @@ -18,6 +18,7 @@ import GripIcon from '../widgets/icons/grip' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' import {useSortableWithGrip} from '../hooks/sortable' +import {Position} from '../components/cardDetail/cardDetailContents' import ContentElement from './content/contentElement' import AddContentMenuItem from './addContentMenuItem' @@ -27,29 +28,35 @@ import './contentBlock.scss' type Props = { block: IContentBlock card: Card - contents: readonly IContentBlock[] readonly: boolean - onDrop: (srctBlock: IContentBlock, dstBlock: IContentBlock) => void + onDrop: (srctBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, position: Position) => void + width?: number + cords: {x: number, y?: number, z?: number} } const ContentBlock = React.memo((props: Props): JSX.Element => { - const {card, contents, block, readonly} = props + const {card, block, readonly, cords} = props const intl = useIntl() - const [isDragging, isOver, gripRef, itemRef] = useSortableWithGrip('content', block, true, props.onDrop) + const [, , gripRef, itemRef] = useSortableWithGrip('content', {block, cords}, true, () => {}) + const [, isOver2,, itemRef2] = useSortableWithGrip('content', {block, cords}, true, (src, dst) => props.onDrop(src, dst, 'right')) + const [, isOver3,, itemRef3] = useSortableWithGrip('content', {block, cords}, true, (src, dst) => props.onDrop(src, dst, 'left')) - const index = contents.indexOf(block) - let className = 'ContentBlock octo-block' - if (isOver) { - className += ' dragover' - } + const index = cords.x + const colIndex = (cords.y || cords.y === 0) && cords.y > -1 ? cords.y : -1 + const contentOrder = card.contentOrder.slice() + + const className = 'ContentBlock octo-block' return (
-
- {!props.readonly && +
+
+ {!props.readonly && }/> @@ -59,18 +66,16 @@ const ContentBlock = React.memo((props: Props): JSX.Element => { name={intl.formatMessage({id: 'ContentBlock.moveUp', defaultMessage: 'Move up'})} icon={} onClick={() => { - const contentOrder = contents.map((o) => o.id) Utils.arrayMove(contentOrder, index, index - 1) mutator.changeCardContentOrder(card, contentOrder) }} />} - {index < (contents.length - 1) && + {index < (contentOrder.length - 1) && } onClick={() => { - const contentOrder = contents.map((o) => o.id) Utils.arrayMove(contentOrder, index, index + 1) mutator.changeCardContentOrder(card, contentOrder) }} @@ -84,9 +89,8 @@ const ContentBlock = React.memo((props: Props): JSX.Element => { ))} @@ -96,7 +100,18 @@ const ContentBlock = React.memo((props: Props): JSX.Element => { name={intl.formatMessage({id: 'ContentBlock.Delete', defaultMessage: 'Delete'})} onClick={() => { const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'}) - const contentOrder = contents.map((o) => o.id).filter((o) => o !== block.id) + + if (colIndex > -1) { + (contentOrder[index] as string[]).splice(colIndex, 1) + } else { + contentOrder.splice(index, 1) + } + + // If only one item in the row, convert form an array item to normal item ( [item] => item ) + if (Array.isArray(contentOrder[index]) && contentOrder[index].length === 1) { + contentOrder[index] = contentOrder[index][0] + } + mutator.performAsUndoGroup(async () => { await mutator.deleteBlock(block, description) await mutator.changeCardContentOrder(card, contentOrder, description) @@ -105,19 +120,31 @@ const ContentBlock = React.memo((props: Props): JSX.Element => { /> - } - {!props.readonly && + } + {!props.readonly && +
+ +
+ } +
+ {!cords.y /* That is to say if cords.y === 0 or cords.y === undefined */ &&
- -
+ ref={itemRef3} + className={`addToRow ${isOver3 ? 'dragover' : ''}`} + style={{flex: 'none', height: '100%'}} + /> } +
-
) diff --git a/webapp/src/components/dialog.scss b/webapp/src/components/dialog.scss index a9097dd3f..a4e52c364 100644 --- a/webapp/src/components/dialog.scss +++ b/webapp/src/components/dialog.scss @@ -77,7 +77,7 @@ } } > .content.fullwidth { - padding: 10px 0 10px 0; + padding-left: 78px; } } } diff --git a/webapp/src/components/gallery/galleryCard.tsx b/webapp/src/components/gallery/galleryCard.tsx index adca05886..7ca634ce6 100644 --- a/webapp/src/components/gallery/galleryCard.tsx +++ b/webapp/src/components/gallery/galleryCard.tsx @@ -42,8 +42,19 @@ const GalleryCard = React.memo((props: Props) => { const visiblePropertyTemplates = props.visiblePropertyTemplates || [] - let images: IContentBlock[] = [] - images = cardTree.contents.filter((content) => content.type === 'image') + let image: IContentBlock | undefined + for (let i = 0; i < cardTree.contents.length; ++i) { + if (Array.isArray(cardTree.contents[i])) { + image = (cardTree.contents[i] as IContentBlock[]).find((c) => c.type === 'image') + } else if ((cardTree.contents[i] as IContentBlock).type === 'image') { + image = cardTree.contents[i] as IContentBlock + } + + if (image) { + break + } + } + let className = props.isSelected ? 'GalleryCard selected' : 'GalleryCard' if (isOver) { className += ' dragover' @@ -81,19 +92,31 @@ const GalleryCard = React.memo((props: Props) => { } - {images?.length > 0 && + {image &&
- +
} - {images?.length === 0 && + {!image &&
- {cardTree && images?.length === 0 && cardTree.contents.map((block) => ( - - ))} + {cardTree?.contents.map((block) => { + if (Array.isArray(block)) { + return block.map((b) => ( + + )) + } + + return ( + + ) + })}
} {props.visibleTitle &&
diff --git a/webapp/src/components/markdownEditor.scss b/webapp/src/components/markdownEditor.scss index c2bfbb8d1..816655640 100644 --- a/webapp/src/components/markdownEditor.scss +++ b/webapp/src/components/markdownEditor.scss @@ -13,6 +13,7 @@ pre.CodeMirror-line { padding: 0; + word-break: break-word; } .CodeMirror, @@ -44,6 +45,7 @@ p { margin: 0; min-height: 32px; + word-break: break-word; } } diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index 4bd64cd70..7b1741685 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -171,7 +171,7 @@ class Mutator { await this.updateBlock(newBoard, board, actionDescription) } - async changeCardContentOrder(card: Card, contentOrder: string[], description = 'reorder'): Promise { + async changeCardContentOrder(card: Card, contentOrder: Array, description = 'reorder'): Promise { const newCard = new MutableCard(card) newCard.contentOrder = contentOrder await this.updateBlock(newCard, card, description) diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 6748ac96a..97bdd423f 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -52,29 +52,6 @@ class OctoUtils { return displayValue } - static relativeBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[], blockA: IBlock, blockB: IBlock): number { - const orderA = partialOrder.indexOf(blockA.id) - const orderB = partialOrder.indexOf(blockB.id) - - if (orderA >= 0 && orderB >= 0) { - // Order of both blocks is specified - return orderA - orderB - } - if (orderA >= 0) { - return -1 - } - if (orderB >= 0) { - return 1 - } - - // Order of both blocks are unspecified, use create date - return blockA.createAt - blockB.createAt - } - - static getBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[]): IBlock[] { - return blocks.slice().sort((a, b) => this.relativeBlockOrder(partialOrder, blocks, a, b)) - } - static hydrateBlock(block: IBlock): MutableBlock { switch (block.type) { case 'board': { return new MutableBoard(block) } @@ -149,7 +126,7 @@ class OctoUtils { // Remap card content order if (newBlock.type === 'card') { const card = newBlock as MutableCard - card.contentOrder = card.contentOrder.map((o) => idMap[o]) + card.contentOrder = card.contentOrder.map((o) => (Array.isArray(o) ? o.map((o2) => idMap[o2]) : idMap[o])) } }) diff --git a/webapp/src/styles/main.scss b/webapp/src/styles/main.scss index 6df04e110..5e230a1b5 100644 --- a/webapp/src/styles/main.scss +++ b/webapp/src/styles/main.scss @@ -203,7 +203,6 @@ h1 { .octo-content { width: 100%; - margin-right: 50px; } .octo-block { @@ -213,9 +212,6 @@ h1 { width: 100%; - @media not screen and (max-width: 975px) { - padding-right: 126px; - } @media screen and (max-width: 975px) { padding-right: 10px; } @@ -233,10 +229,8 @@ h1 { flex-direction: row; align-items: flex-start; justify-content: flex-end; - padding-top: 10px; - padding-right: 10px; @media not screen and (max-width: 975px) { - width: 126px; + width: 48px; } } diff --git a/webapp/src/viewModel/cardTree.test.ts b/webapp/src/viewModel/cardTree.test.ts index d59a06b0d..0598adf25 100644 --- a/webapp/src/viewModel/cardTree.test.ts +++ b/webapp/src/viewModel/cardTree.test.ts @@ -57,8 +57,9 @@ test('CardTree', async () => { const image2 = TestBlockFactory.createImage(card) await Utils.sleep(10) const divider2 = TestBlockFactory.createDivider(card) + card.contentOrder.push(...[text2.id, image2.id, divider2.id]) - cardTree = MutableCardTree.incrementalUpdate(cardTree, [comment2, text2, image2, divider2]) + cardTree = MutableCardTree.incrementalUpdate(cardTree, [card, comment2, text2, image2, divider2]) expect(cardTree).not.toBeUndefined() if (!cardTree) { fail('incrementalUpdate') @@ -79,6 +80,7 @@ test('CardTree', async () => { fail('incrementalUpdate') } + card.contentOrder = [text.id, image.id, divider.id] FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([card, comment, text, image, divider]))) cardTree = await MutableCardTree.sync(card.id) expect(cardTree).not.toBeUndefined() diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 018805c0a..a5aa86267 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react' -import {ContentBlockTypes, contentBlockTypes, IBlock} from '../blocks/block' +import {ContentBlockTypes, contentBlockTypes, IBlock, MutableBlock} from '../blocks/block' import {Card, MutableCard} from '../blocks/card' import {CommentBlock} from '../blocks/commentBlock' import {IContentBlock} from '../blocks/contentBlock' @@ -12,7 +12,7 @@ import {OctoUtils} from '../octoUtils' interface CardTree { readonly card: Card readonly comments: readonly CommentBlock[] - readonly contents: readonly IContentBlock[] + readonly contents: Readonly> readonly allBlocks: readonly IBlock[] readonly latestBlock: IBlock } @@ -20,11 +20,11 @@ interface CardTree { class MutableCardTree implements CardTree { card: MutableCard comments: CommentBlock[] = [] - contents: IContentBlock[] = [] + contents: (IContentBlock[] | IContentBlock)[] = [] latestBlock: IBlock get allBlocks(): IBlock[] { - return [this.card, ...this.comments, ...this.contents] + return [this.card, ...this.comments, ...this.contents.flat()] } constructor(card: MutableCard) { @@ -62,7 +62,14 @@ class MutableCardTree implements CardTree { sort((a, b) => a.createAt - b.createAt) as CommentBlock[] const contentBlocks = blocks.filter((block) => contentBlockTypes.includes(block.type as ContentBlockTypes)) as IContentBlock[] - cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks) + + cardTree.contents = card.contentOrder.map((contentIds) => { + if (Array.isArray(contentIds)) { + return contentIds.map((contentId) => contentBlocks.find((content) => content.id === contentId)).filter((content): content is IContentBlock => Boolean(content)) + } + + return contentBlocks.find((content) => content.id === contentIds) || new MutableBlock() + }) cardTree.latestBlock = MutableCardTree.getMostRecentBlock(cardTree)