diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 20aff17b9..f367e3db0 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -15,22 +15,14 @@ "CardDetail.add-icon": "Add icon", "CardDetail.add-property": "+ Add a property", "CardDetail.addCardText": "add card text", - "CardDetail.image": "Image", "CardDetail.new-comment-placeholder": "Add a comment...", - "CardDetail.text": "Text", "CardDialog.editing-template": "You're editing a template", "CardDialog.nocard": "This card doesn't exist or is inaccessible", "Comment.delete": "Delete", "CommentsList.send": "Send", "ContentBlock.Delete": "Delete", "ContentBlock.DeleteAction": "delete", - "ContentBlock.Text": "Text", - "ContentBlock.addDivider": "add divider", - "ContentBlock.addImage": "add image", - "ContentBlock.addText": "add text", - "ContentBlock.divider": "Divider", - "ContentBlock.editCardText": "edit card text", - "ContentBlock.editText": "Edit text...", + "ContentBlock.addElement": "add {type}", "ContentBlock.insertAbove": "Insert above", "ContentBlock.moveDown": "Move down", "ContentBlock.moveUp": "Move up", @@ -98,7 +90,6 @@ "Sidebar.settings": "Settings", "Sidebar.spanish": "Spanish", "Sidebar.template-from-board": "New template from board", - "Sidebar.title": "Boards", "Sidebar.untitled": "Untitled", "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", diff --git a/webapp/src/blocks/block.ts b/webapp/src/blocks/block.ts index 17a83ae2e..83a5304e8 100644 --- a/webapp/src/blocks/block.ts +++ b/webapp/src/blocks/block.ts @@ -2,7 +2,10 @@ // See LICENSE.txt for license information. import {Utils} from '../utils' -type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'comment' +const contentBlockTypes = ['text', 'image', 'divider'] as const +const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment'] as const +type ContentBlockTypes = typeof contentBlockTypes[number] +type BlockTypes = typeof blockTypes[number] interface IBlock { readonly id: string @@ -69,4 +72,5 @@ class MutableBlock implements IMutableBlock { } } -export {IBlock, IMutableBlock, MutableBlock} +export type {ContentBlockTypes, BlockTypes} +export {blockTypes, contentBlockTypes, IBlock, IMutableBlock, MutableBlock} diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index f5f849044..3a944a1b0 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -4,6 +4,7 @@ import React from 'react' import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {BlockIcons} from '../blockIcons' +import {BlockTypes} from '../blocks/block' import {PropertyType} from '../blocks/board' import {MutableTextBlock} from '../blocks/textBlock' import mutator from '../mutator' @@ -20,6 +21,7 @@ import PropertyMenu from '../widgets/propertyMenu' import BlockIconSelector from './blockIconSelector' import './cardDetail.scss' import CommentsList from './commentsList' +import {ContentHandler, contentRegistry} from './content/contentRegistry' import ContentBlock from './contentBlock' import {MarkdownEditor} from './markdownEditor' import PropertyValueElement from './propertyValueElement' @@ -56,7 +58,7 @@ class CardDetail extends React.Component { } render() { - const {boardTree, cardTree, intl} = this.props + const {boardTree, cardTree} = this.props const {board} = boardTree if (!cardTree) { return null @@ -221,31 +223,7 @@ class CardDetail extends React.Component { /> - { - this.addTextBlock('') - }} - /> - Utils.selectLocalFile((file) => { - mutator.performAsUndoGroup(async () => { - const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'}) - const newBlock = await mutator.createImageBlock(card, file, description) - if (newBlock) { - const contentOrder = card.contentOrder.slice() - contentOrder.push(newBlock.id) - await mutator.changeCardContentOrder(card, contentOrder, description) - } - }) - }, - '.jpg,.jpeg,.png', - )} - /> - + {contentRegistry.contentTypes.map((type) => this.addContentMenu(type))} @@ -254,6 +232,46 @@ class CardDetail extends React.Component { ) } + private addContentMenu(type: BlockTypes): JSX.Element { + const {intl} = this.props + + const handler = contentRegistry.getHandler(type) + if (!handler) { + Utils.logError(`addContentMenu, unknown content type: ${type}`) + return <> + } + + return ( + { + this.addBlock(handler) + }} + /> + ) + } + + private async addBlock(handler: ContentHandler) { + const {intl, cardTree} = this.props + const {card} = cardTree + + const newBlock = await handler.createBlock() + newBlock.parentId = card.id + newBlock.rootId = card.rootId + + const contentOrder = card.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, contentOrder, description) + }) + } + private addTextBlock(text: string): void { const {intl, cardTree} = this.props const {card} = cardTree diff --git a/webapp/src/components/content/contentElement.tsx b/webapp/src/components/content/contentElement.tsx new file mode 100644 index 000000000..4a1c7eed4 --- /dev/null +++ b/webapp/src/components/content/contentElement.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' +import {injectIntl, IntlShape} from 'react-intl' + +import {IContentBlock} from '../../blocks/contentBlock' +import {Utils} from '../../utils' + +import {contentRegistry} from './contentRegistry' + +// Need to require here to prevent webpack from tree-shaking these away +// TODO: Update webpack to avoid this +import './textElement' +import './imageElement' +import './dividerElement' + +type Props = { + block: IContentBlock + readonly: boolean + intl: IntlShape +} + +class ContentElement extends React.PureComponent { + public render(): JSX.Element | null { + const {block, intl, readonly} = this.props + + const handler = contentRegistry.getHandler(block.type) + if (!handler) { + Utils.logError(`ContentElement, unknown content type: ${block.type}`) + return null + } + + return handler.createComponent(block, intl, readonly) + } +} + +export default injectIntl(ContentElement) diff --git a/webapp/src/components/content/contentRegistry.tsx b/webapp/src/components/content/contentRegistry.tsx new file mode 100644 index 000000000..834b34536 --- /dev/null +++ b/webapp/src/components/content/contentRegistry.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable react/require-optimization */ +import {IntlShape} from 'react-intl' + +import {BlockTypes} from '../../blocks/block' +import {IContentBlock, MutableContentBlock} from '../../blocks/contentBlock' +import {Utils} from '../../utils' + +type ContentHandler = { + type: BlockTypes, + getDisplayText: (intl: IntlShape) => string, + getIcon: () => JSX.Element, + createBlock: () => Promise, + createComponent: (block: IContentBlock, intl: IntlShape, readonly: boolean) => JSX.Element, +} + +class ContentRegistry { + private registry: Map = new Map() + + get contentTypes(): BlockTypes[] { + return [...this.registry.keys()] + } + + registerContentType(entry: ContentHandler) { + if (this.isContentType(entry.type)) { + Utils.logError(`registerContentType, already registered type: ${entry.type}`) + return + } + this.registry.set(entry.type, entry) + } + + isContentType(type: BlockTypes): boolean { + return this.registry.has(type) + } + + getHandler(type: BlockTypes): ContentHandler | undefined { + return this.registry.get(type) + } +} + +const contentRegistry = new ContentRegistry() + +export type {ContentHandler} +export {contentRegistry} + diff --git a/webapp/src/components/content/dividerElement.scss b/webapp/src/components/content/dividerElement.scss new file mode 100644 index 000000000..2821e4a36 --- /dev/null +++ b/webapp/src/components/content/dividerElement.scss @@ -0,0 +1,6 @@ +.DividerElement { + padding-top: 16px; + border-bottom: 1px solid rgba(var(--body-color), 0.09); + margin-bottom: 17px; + flex-grow: 1; +} diff --git a/webapp/src/components/content/dividerElement.tsx b/webapp/src/components/content/dividerElement.tsx new file mode 100644 index 000000000..e286fffb8 --- /dev/null +++ b/webapp/src/components/content/dividerElement.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' + +import {MutableDividerBlock} from '../../blocks/dividerBlock' +import DividerIcon from '../../widgets/icons/divider' + +import {contentRegistry} from './contentRegistry' +import './dividerElement.scss' + +class DividerElement extends React.PureComponent { + render(): JSX.Element { + return
+ } +} + +contentRegistry.registerContentType({ + type: 'divider', + getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'divider'}), + getIcon: () => , + createBlock: async () => { + return new MutableDividerBlock() + }, + createComponent: () => , +}) + +export default DividerElement diff --git a/webapp/src/components/content/imageElement.tsx b/webapp/src/components/content/imageElement.tsx new file mode 100644 index 000000000..c8f8e28a9 --- /dev/null +++ b/webapp/src/components/content/imageElement.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' +import {injectIntl, IntlShape} from 'react-intl' + +import {IContentBlock} from '../../blocks/contentBlock' +import {MutableImageBlock} from '../../blocks/imageBlock' +import octoClient from '../../octoClient' +import {Utils} from '../../utils' +import ImageIcon from '../../widgets/icons/image' + +import {contentRegistry} from './contentRegistry' + +type Props = { + block: IContentBlock + intl: IntlShape +} + +type State = { + imageDataUrl?: string +} + +class ImageElement extends React.PureComponent { + state: State = {} + + componentDidMount(): void { + if (!this.state.imageDataUrl) { + this.loadImage() + } + } + + private async loadImage() { + const imageDataUrl = await octoClient.getFileAsDataUrl(this.props.block.fields.fileId) + this.setState({imageDataUrl}) + } + + public render(): JSX.Element | null { + const {block} = this.props + const {imageDataUrl} = this.state + + if (!imageDataUrl) { + return null + } + + return ( + {block.title} + ) + } +} + +contentRegistry.registerContentType({ + type: 'image', + getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.image', defaultMessage: 'image'}), + getIcon: () => , + createBlock: async () => { + return new Promise( + (resolve) => { + Utils.selectLocalFile(async (file) => { + const fileId = await octoClient.uploadFile(file) + + const block = new MutableImageBlock() + block.fileId = fileId || '' + resolve(block) + }, + '.jpg,.jpeg,.png') + }, + ) + + // return new MutableImageBlock() + }, + createComponent: (block, intl) => { + return ( + + ) + }, +}) + +export default injectIntl(ImageElement) diff --git a/webapp/src/components/content/textElement.tsx b/webapp/src/components/content/textElement.tsx new file mode 100644 index 000000000..97dc90c2d --- /dev/null +++ b/webapp/src/components/content/textElement.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' +import {injectIntl, IntlShape} from 'react-intl' + +import {IContentBlock} from '../../blocks/contentBlock' +import {MutableTextBlock} from '../../blocks/textBlock' +import mutator from '../../mutator' +import TextIcon from '../../widgets/icons/text' +import {MarkdownEditor} from '../markdownEditor' + +import {contentRegistry} from './contentRegistry' + +type Props = { + block: IContentBlock + readonly: boolean + intl: IntlShape +} + +class TextElement extends React.PureComponent { + render(): JSX.Element { + const {intl, block, readonly} = this.props + + return ( + { + mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'})) + }} + readonly={readonly} + /> + ) + } +} + +contentRegistry.registerContentType({ + type: 'text', + getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.text', defaultMessage: 'text'}), + getIcon: () => , + createBlock: async () => { + return new MutableTextBlock() + }, + createComponent: (block, intl, readonly) => { + return ( + + ) + }, +}) + +export default injectIntl(TextElement) diff --git a/webapp/src/components/contentBlock.scss b/webapp/src/components/contentBlock.scss index f26c03975..2933c1cf5 100644 --- a/webapp/src/components/contentBlock.scss +++ b/webapp/src/components/contentBlock.scss @@ -7,12 +7,6 @@ display: flex; } } - .divider { - padding-top: 16px; - border-bottom: 1px solid rgba(var(--body-color), 0.09); - margin-bottom: 17px; - flex-grow: 1; - } > * { flex: 1 1 auto; } diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index e4cc69047..a969e2b87 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -4,27 +4,23 @@ import React from 'react' import {injectIntl, IntlShape} from 'react-intl' +import {BlockTypes} from '../blocks/block' import {Card} from '../blocks/card' import {IContentBlock} from '../blocks/contentBlock' -import {MutableDividerBlock} from '../blocks/dividerBlock' -import {MutableTextBlock} from '../blocks/textBlock' import mutator from '../mutator' -import octoClient from '../octoClient' import {Utils} from '../utils' import IconButton from '../widgets/buttons/iconButton' import AddIcon from '../widgets/icons/add' import DeleteIcon from '../widgets/icons/delete' -import DividerIcon from '../widgets/icons/divider' -import ImageIcon from '../widgets/icons/image' import OptionsIcon from '../widgets/icons/options' import SortDownIcon from '../widgets/icons/sortDown' import SortUpIcon from '../widgets/icons/sortUp' -import TextIcon from '../widgets/icons/text' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' +import ContentElement from './content/contentElement' +import {contentRegistry} from './content/contentRegistry' import './contentBlock.scss' -import {MarkdownEditor} from './markdownEditor' type Props = { block: IContentBlock @@ -34,31 +30,9 @@ type Props = { intl: IntlShape } -type State = { - imageDataUrl?: string -} - -class ContentBlock extends React.PureComponent { - state: State = {} - - componentDidMount(): void { - if (this.props.block.type === 'image' && !this.state.imageDataUrl) { - this.loadImage() - } - } - - private async loadImage() { - const imageDataUrl = await octoClient.getFileAsDataUrl(this.props.block.fields.fileId) - this.setState({imageDataUrl}) - } - +class ContentBlock extends React.PureComponent { public render(): JSX.Element | null { - const {intl, card, contents, block} = this.props - - if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') { - Utils.assertFailure(`Block type is unknown: ${block.type}`) - return null - } + const {intl, card, contents, block, readonly} = this.props const index = contents.indexOf(block) return ( @@ -95,61 +69,7 @@ class ContentBlock extends React.PureComponent { name={intl.formatMessage({id: 'ContentBlock.insertAbove', defaultMessage: 'Insert above'})} icon={} > - } - onClick={() => { - const newBlock = new MutableTextBlock() - newBlock.parentId = card.id - newBlock.rootId = card.rootId - - const contentOrder = contents.map((o) => o.id) - contentOrder.splice(index, 0, newBlock.id) - mutator.performAsUndoGroup(async () => { - const description = intl.formatMessage({id: 'ContentBlock.addText', defaultMessage: 'add text'}) - await mutator.insertBlock(newBlock, description) - await mutator.changeCardContentOrder(card, contentOrder, description) - }) - }} - /> - } - onClick={() => { - Utils.selectLocalFile((file) => { - mutator.performAsUndoGroup(async () => { - const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'}) - const newBlock = await mutator.createImageBlock(card, file, description) - if (newBlock) { - const contentOrder = contents.map((o) => o.id) - contentOrder.splice(index, 0, newBlock.id) - await mutator.changeCardContentOrder(card, contentOrder, description) - } - }) - }, - '.jpg,.jpeg,.png') - }} - /> - } - onClick={() => { - const newBlock = new MutableDividerBlock() - newBlock.parentId = card.id - newBlock.rootId = card.rootId - - const contentOrder = contents.map((o) => o.id) - contentOrder.splice(index, 0, newBlock.id) - mutator.performAsUndoGroup(async () => { - const description = intl.formatMessage({id: 'ContentBlock.addDivider', defaultMessage: 'add divider'}) - await mutator.insertBlock(newBlock, description) - await mutator.changeCardContentOrder(card, contentOrder, description) - }) - }} - /> + {contentRegistry.contentTypes.map((type) => this.addContentMenu(type))} } @@ -168,24 +88,47 @@ class ContentBlock extends React.PureComponent { }
- {block.type === 'text' && - { - mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'})) - }} - readonly={this.props.readonly} - />} - {block.type === 'divider' &&
} - {block.type === 'image' && this.state.imageDataUrl && - {block.title}} +
) } + + private addContentMenu(type: BlockTypes): JSX.Element { + const {intl, card, contents, block} = this.props + const index = contents.indexOf(block) + + const handler = contentRegistry.getHandler(type) + if (!handler) { + Utils.logError(`addContentMenu, unknown content type: ${type}`) + return <> + } + + return ( + { + const newBlock = await handler.createBlock() + 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}) + mutator.performAsUndoGroup(async () => { + await mutator.insertBlock(newBlock, description) + await mutator.changeCardContentOrder(card, contentOrder, description) + }) + }} + /> + ) + } } export default injectIntl(ContentBlock) diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 0d7cf20b4..92f423bbe 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {IBlock} from '../blocks/block' +import {ContentBlockTypes, contentBlockTypes, IBlock} from '../blocks/block' import {Card, MutableCard} from '../blocks/card' import {CommentBlock} from '../blocks/commentBlock' import {IContentBlock} from '../blocks/contentBlock' @@ -56,7 +56,7 @@ class MutableCardTree implements CardTree { filter((block) => block.type === 'comment'). sort((a, b) => a.createAt - b.createAt) as CommentBlock[] - const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IContentBlock[] + const contentBlocks = blocks.filter((block) => contentBlockTypes.includes(block.type as ContentBlockTypes)) as IContentBlock[] cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks) return cardTree