mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-25 02:48:37 -04:00
Add blocks editor
This commit is contained in:
parent
fe7b6d89eb
commit
bf1d189c79
5 changed files with 1669 additions and 11 deletions
|
|
@ -16,6 +16,9 @@ import {translateBlockKit} from 'components/block_renderer/translation/block_kit
|
|||
import {translateMMBlocks} from 'components/block_renderer/translation/mm_block';
|
||||
import PostContext from 'components/post_view/post_context';
|
||||
|
||||
import {type BlockPath, serializeMmBlocks} from './mm_blocks_editor_utils';
|
||||
import MmBlocksHierarchyEditor from './mm_blocks_hierarchy_editor';
|
||||
|
||||
import './component_library.scss';
|
||||
|
||||
type InputMode = 'mm_blocks' | 'attachments' | 'block_kit' | 'adaptive_cards';
|
||||
|
|
@ -164,20 +167,31 @@ const MmBlocksComponentLibrary = ({
|
|||
}: Props) => {
|
||||
const [inputMode, setInputMode] = useState<InputMode>('mm_blocks');
|
||||
const [drafts, setDrafts] = useState<Record<InputMode, string>>(() => ({...INITIAL_DRAFTS}));
|
||||
const [selectedBlockPath, setSelectedBlockPath] = useState<BlockPath | null>(null);
|
||||
|
||||
const jsonText = drafts[inputMode];
|
||||
|
||||
const parsed = useMemo(() => parsePayload(jsonText, inputMode), [jsonText, inputMode]);
|
||||
|
||||
const showMmBlocksEditor = inputMode === 'mm_blocks' && parsed.ok;
|
||||
|
||||
const onChangeMode = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setInputMode(e.target.value as InputMode);
|
||||
setSelectedBlockPath(null);
|
||||
}, []);
|
||||
|
||||
const onChangeJson = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setDrafts((d) => ({...d, [inputMode]: value}));
|
||||
if (inputMode === 'mm_blocks') {
|
||||
setSelectedBlockPath(null);
|
||||
}
|
||||
}, [inputMode]);
|
||||
|
||||
const onHierarchyBlocksChange = useCallback((blocks: MmBlock[]) => {
|
||||
setDrafts((d) => ({...d, mm_blocks: serializeMmBlocks(blocks)}));
|
||||
}, []);
|
||||
|
||||
const onAction = useCallback((actionId: string, selectedOption?: string, query?: Record<string, string>, attachmentCookie?: string) => {
|
||||
const parts = [
|
||||
`action_id: ${actionId}`,
|
||||
|
|
@ -218,17 +232,27 @@ const MmBlocksComponentLibrary = ({
|
|||
{modeOptions}
|
||||
</select>
|
||||
</label>
|
||||
<label className='clInput'>
|
||||
{'JSON: '}
|
||||
<textarea
|
||||
className='clJsonEditor'
|
||||
spellCheck={false}
|
||||
value={jsonText}
|
||||
onChange={onChangeJson}
|
||||
rows={16}
|
||||
aria-label='Interactive message JSON'
|
||||
/>
|
||||
</label>
|
||||
<div className={showMmBlocksEditor ? 'clMmBlocksEditorLayout' : undefined}>
|
||||
<label className={classNames('clInput', showMmBlocksEditor && 'clMmBlocksEditorLayout__json')}>
|
||||
{'JSON: '}
|
||||
<textarea
|
||||
className='clJsonEditor'
|
||||
spellCheck={false}
|
||||
value={jsonText}
|
||||
onChange={onChangeJson}
|
||||
rows={16}
|
||||
aria-label='Interactive message JSON'
|
||||
/>
|
||||
</label>
|
||||
{showMmBlocksEditor && (
|
||||
<MmBlocksHierarchyEditor
|
||||
blocks={parsed.blocks}
|
||||
selectedPath={selectedBlockPath}
|
||||
onSelectPath={setSelectedBlockPath}
|
||||
onChangeBlocks={onHierarchyBlocksChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!parsed.ok && (
|
||||
<div
|
||||
className='clJsonError'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {MmBlock} from '@mattermost/types/mm_blocks';
|
||||
|
||||
import {
|
||||
createDefaultBlock,
|
||||
getBlockAt,
|
||||
insertBlockAt,
|
||||
moveBlockAt,
|
||||
parsePathKey,
|
||||
pathKey,
|
||||
remapPathAfterMove,
|
||||
removeBlockAt,
|
||||
sameParentList,
|
||||
serializeMmBlocks,
|
||||
updateBlockAt,
|
||||
} from './mm_blocks_editor_utils';
|
||||
|
||||
describe('mm_blocks_editor_utils', () => {
|
||||
const sample: MmBlock[] = [
|
||||
{type: 'text', text: 'Hello'},
|
||||
{
|
||||
type: 'container',
|
||||
content: [{type: 'divider'}],
|
||||
},
|
||||
];
|
||||
|
||||
test('getBlockAt resolves nested path', () => {
|
||||
const path = [{list: 'root', index: 1}, {list: 'content', index: 0}] as const;
|
||||
expect(getBlockAt(sample, [...path])?.type).toBe('divider');
|
||||
});
|
||||
|
||||
test('updateBlockAt changes block text', () => {
|
||||
const path = [{list: 'root' as const, index: 0}];
|
||||
const next = updateBlockAt(sample, path, {type: 'text', text: 'Updated'});
|
||||
expect(getBlockAt(next, path)).toEqual({type: 'text', text: 'Updated'});
|
||||
});
|
||||
|
||||
test('removeBlockAt removes root block', () => {
|
||||
const path = [{list: 'root' as const, index: 0}];
|
||||
const next = removeBlockAt(sample, path);
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].type).toBe('container');
|
||||
});
|
||||
|
||||
test('insertBlockAt adds sibling', () => {
|
||||
const path = [{list: 'root' as const, index: 0}];
|
||||
const next = insertBlockAt(sample, path, {type: 'divider'}, 'sibling');
|
||||
expect(next).toHaveLength(3);
|
||||
expect(next[1].type).toBe('divider');
|
||||
});
|
||||
|
||||
test('serializeMmBlocks produces formatted JSON', () => {
|
||||
expect(serializeMmBlocks([{type: 'text', text: 'x'}])).toContain('\n');
|
||||
});
|
||||
|
||||
test('pathKey is stable', () => {
|
||||
expect(pathKey([{list: 'root', index: 0}, {list: 'content', index: 1}])).toBe('root:0/content:1');
|
||||
});
|
||||
|
||||
test('createDefaultBlock returns valid shapes', () => {
|
||||
expect(createDefaultBlock('column_set').type).toBe('column_set');
|
||||
});
|
||||
|
||||
test('parsePathKey round-trips pathKey', () => {
|
||||
const path = [{list: 'root', index: 1}, {list: 'content', index: 0}] as const;
|
||||
expect(parsePathKey(pathKey([...path]))).toEqual([...path]);
|
||||
});
|
||||
|
||||
test('sameParentList detects siblings', () => {
|
||||
expect(sameParentList(
|
||||
[{list: 'root', index: 0}],
|
||||
[{list: 'root', index: 1}],
|
||||
)).toBe(true);
|
||||
expect(sameParentList(
|
||||
[{list: 'root', index: 0}, {list: 'content', index: 0}],
|
||||
[{list: 'root', index: 0}, {list: 'content', index: 1}],
|
||||
)).toBe(true);
|
||||
expect(sameParentList(
|
||||
[{list: 'root', index: 0}],
|
||||
[{list: 'root', index: 0}, {list: 'content', index: 0}],
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('moveBlockAt reorders within a list', () => {
|
||||
const blocks: MmBlock[] = [
|
||||
{type: 'text', text: 'A'},
|
||||
{type: 'text', text: 'B'},
|
||||
{type: 'text', text: 'C'},
|
||||
];
|
||||
const from = [{list: 'root' as const, index: 0}];
|
||||
const next = moveBlockAt(blocks, from, 2);
|
||||
expect(next.map((b) => (b as {text: string}).text)).toEqual(['B', 'C', 'A']);
|
||||
});
|
||||
|
||||
test('remapPathAfterMove updates moved and shifted paths', () => {
|
||||
const from = [{list: 'root' as const, index: 0}];
|
||||
expect(remapPathAfterMove(from, from, 2)).toEqual([{list: 'root', index: 2}]);
|
||||
expect(remapPathAfterMove([{list: 'root', index: 2}], from, 2)).toEqual([{list: 'root', index: 1}]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,620 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {
|
||||
MmBlock,
|
||||
MmButtonStyle,
|
||||
MmColumnBlock,
|
||||
MmContainerBackground,
|
||||
MmContainerGap,
|
||||
MmContainerMaxHeight,
|
||||
MmImageSize,
|
||||
MmStaticSelectOption,
|
||||
MmTextSize,
|
||||
} from '@mattermost/types/mm_blocks';
|
||||
|
||||
/** Identifies which child array a path segment refers to. */
|
||||
export type ChildListKey = 'root' | 'content' | 'items' | 'columns' | 'header';
|
||||
|
||||
export type PathSegment = {
|
||||
list: ChildListKey;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type BlockPath = PathSegment[];
|
||||
|
||||
export type AddBlockTarget = 'sibling' | 'child';
|
||||
|
||||
export type BlockTypeId =
|
||||
| 'text'
|
||||
| 'divider'
|
||||
| 'button'
|
||||
| 'static_select'
|
||||
| 'image'
|
||||
| 'column'
|
||||
| 'column_set'
|
||||
| 'container'
|
||||
| 'collapsible';
|
||||
|
||||
export type PropertyFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'json';
|
||||
|
||||
export type PropertyField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: PropertyFieldType;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const ROOT_ADDABLE_TYPES: BlockTypeId[] = [
|
||||
'text',
|
||||
'divider',
|
||||
'button',
|
||||
'static_select',
|
||||
'image',
|
||||
'container',
|
||||
'column_set',
|
||||
'collapsible',
|
||||
];
|
||||
|
||||
export const COLUMN_SET_ADDABLE_TYPES: BlockTypeId[] = ['column'];
|
||||
|
||||
const BLOCK_TYPE_LABELS: Record<BlockTypeId, string> = {
|
||||
text: 'Text',
|
||||
divider: 'Divider',
|
||||
button: 'Button',
|
||||
static_select: 'Static select',
|
||||
image: 'Image',
|
||||
column: 'Column',
|
||||
column_set: 'Column set',
|
||||
container: 'Container',
|
||||
collapsible: 'Collapsible',
|
||||
};
|
||||
|
||||
export function blockTypeLabel(type: BlockTypeId): string {
|
||||
return BLOCK_TYPE_LABELS[type];
|
||||
}
|
||||
|
||||
export function createDefaultBlock(type: BlockTypeId): MmBlock {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return {type: 'text', text: 'New text'};
|
||||
case 'divider':
|
||||
return {type: 'divider'};
|
||||
case 'button':
|
||||
return {type: 'button', text: 'Button', action_id: 'action_id', style: 'default'};
|
||||
case 'static_select':
|
||||
return {
|
||||
type: 'static_select',
|
||||
action_id: 'select_action',
|
||||
placeholder: 'Choose an option',
|
||||
options: [
|
||||
{text: 'Option A', value: 'a'},
|
||||
{text: 'Option B', value: 'b'},
|
||||
],
|
||||
};
|
||||
case 'image':
|
||||
return {
|
||||
type: 'image',
|
||||
url: 'https://example.com/image.png',
|
||||
alt_text: 'Image description',
|
||||
};
|
||||
case 'column':
|
||||
return {
|
||||
type: 'column',
|
||||
items: [{type: 'text', text: 'Column content'}],
|
||||
};
|
||||
case 'column_set':
|
||||
return {
|
||||
type: 'column_set',
|
||||
columns: [
|
||||
{
|
||||
type: 'column',
|
||||
items: [{type: 'text', text: 'Column 1'}],
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
items: [{type: 'text', text: 'Column 2'}],
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'container':
|
||||
return {
|
||||
type: 'container',
|
||||
content: [{type: 'text', text: 'Container content'}],
|
||||
};
|
||||
case 'collapsible':
|
||||
return {
|
||||
type: 'collapsible',
|
||||
header: [{type: 'text', text: 'Header'}],
|
||||
content: [{type: 'text', text: 'Collapsed content'}],
|
||||
};
|
||||
default:
|
||||
return {type: 'text', text: 'New text'};
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeMmBlocks(blocks: MmBlock[]): string {
|
||||
return JSON.stringify(blocks, null, 2);
|
||||
}
|
||||
|
||||
function getChildList(block: MmBlock | MmColumnBlock, list: ChildListKey): MmBlock[] | MmColumnBlock[] | null {
|
||||
switch (block.type) {
|
||||
case 'container':
|
||||
if (list === 'content') {
|
||||
return block.content;
|
||||
}
|
||||
return null;
|
||||
case 'column':
|
||||
if (list === 'items') {
|
||||
return block.items;
|
||||
}
|
||||
return null;
|
||||
case 'column_set':
|
||||
if (list === 'columns') {
|
||||
return block.columns;
|
||||
}
|
||||
return null;
|
||||
case 'collapsible':
|
||||
if (list === 'header') {
|
||||
return block.header;
|
||||
}
|
||||
if (list === 'content') {
|
||||
return block.content;
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getBlockAt(root: MmBlock[], path: BlockPath): MmBlock | MmColumnBlock | null {
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let list: Array<MmBlock | MmColumnBlock> = root;
|
||||
let block: MmBlock | MmColumnBlock | null = null;
|
||||
for (const segment of path) {
|
||||
if (segment.list === 'root') {
|
||||
block = list[segment.index] ?? null;
|
||||
} else if (block) {
|
||||
const childList = getChildList(block, segment.list);
|
||||
if (!childList) {
|
||||
return null;
|
||||
}
|
||||
list = childList;
|
||||
block = list[segment.index] ?? null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
export function getParentContext(
|
||||
root: MmBlock[],
|
||||
path: BlockPath,
|
||||
): {list: MmBlock[] | MmColumnBlock[]; index: number; parentBlock: MmBlock | MmColumnBlock | null} | null {
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const last = path[path.length - 1];
|
||||
if (path.length === 1) {
|
||||
return {list: root, index: last.index, parentBlock: null};
|
||||
}
|
||||
const parentPath = path.slice(0, -1);
|
||||
const parentBlock = getBlockAt(root, parentPath);
|
||||
if (!parentBlock || last.list === 'root') {
|
||||
return null;
|
||||
}
|
||||
const list = getChildList(parentBlock, last.list);
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
return {list, index: last.index, parentBlock};
|
||||
}
|
||||
|
||||
export function cloneBlocks(blocks: MmBlock[]): MmBlock[] {
|
||||
return JSON.parse(JSON.stringify(blocks)) as MmBlock[];
|
||||
}
|
||||
|
||||
export function updateBlockAt(root: MmBlock[], path: BlockPath, next: MmBlock | MmColumnBlock): MmBlock[] {
|
||||
const draft = cloneBlocks(root);
|
||||
const ctx = getParentContext(draft, path);
|
||||
if (!ctx) {
|
||||
return draft;
|
||||
}
|
||||
ctx.list[ctx.index] = next as MmBlock & MmColumnBlock;
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function removeBlockAt(root: MmBlock[], path: BlockPath): MmBlock[] {
|
||||
const draft = cloneBlocks(root);
|
||||
const ctx = getParentContext(draft, path);
|
||||
if (!ctx) {
|
||||
return draft;
|
||||
}
|
||||
ctx.list.splice(ctx.index, 1);
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function insertBlockAt(
|
||||
root: MmBlock[],
|
||||
path: BlockPath,
|
||||
block: MmBlock | MmColumnBlock,
|
||||
target: AddBlockTarget,
|
||||
): MmBlock[] {
|
||||
const draft = cloneBlocks(root);
|
||||
if (target === 'sibling') {
|
||||
const ctx = getParentContext(draft, path);
|
||||
if (!ctx) {
|
||||
return draft;
|
||||
}
|
||||
ctx.list.splice(ctx.index + 1, 0, block as MmBlock & MmColumnBlock);
|
||||
return draft;
|
||||
}
|
||||
|
||||
const current = getBlockAt(draft, path);
|
||||
if (!current) {
|
||||
return draft;
|
||||
}
|
||||
const childListKey = defaultChildListKey(current);
|
||||
if (!childListKey) {
|
||||
return draft;
|
||||
}
|
||||
const list = getChildList(current, childListKey);
|
||||
if (!list) {
|
||||
return draft;
|
||||
}
|
||||
list.push(block as MmBlock & MmColumnBlock);
|
||||
return draft;
|
||||
}
|
||||
|
||||
function defaultChildListKey(block: MmBlock | MmColumnBlock): ChildListKey | null {
|
||||
switch (block.type) {
|
||||
case 'container':
|
||||
return 'content';
|
||||
case 'column':
|
||||
return 'items';
|
||||
case 'column_set':
|
||||
return 'columns';
|
||||
case 'collapsible':
|
||||
return 'content';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function canAddChild(block: MmBlock | MmColumnBlock): boolean {
|
||||
return defaultChildListKey(block) !== null;
|
||||
}
|
||||
|
||||
export function addableTypesForList(list: ChildListKey): BlockTypeId[] {
|
||||
if (list === 'columns') {
|
||||
return COLUMN_SET_ADDABLE_TYPES;
|
||||
}
|
||||
return ROOT_ADDABLE_TYPES;
|
||||
}
|
||||
|
||||
export function pathKey(path: BlockPath): string {
|
||||
return path.map((s) => `${s.list}:${s.index}`).join('/');
|
||||
}
|
||||
|
||||
export function parsePathKey(key: string): BlockPath | null {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const segments: BlockPath = [];
|
||||
for (const part of key.split('/')) {
|
||||
const colon = part.indexOf(':');
|
||||
if (colon === -1) {
|
||||
return null;
|
||||
}
|
||||
const list = part.slice(0, colon) as ChildListKey;
|
||||
const index = Number.parseInt(part.slice(colon + 1), 10);
|
||||
if (!Number.isFinite(index) || index < 0) {
|
||||
return null;
|
||||
}
|
||||
segments.push({list, index});
|
||||
}
|
||||
return segments.length > 0 ? segments : null;
|
||||
}
|
||||
|
||||
export function parentPathKey(path: BlockPath): string {
|
||||
if (path.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
return pathKey(path.slice(0, -1));
|
||||
}
|
||||
|
||||
/** True when both paths refer to siblings in the same array (same parent, same list key). */
|
||||
export function sameParentList(a: BlockPath, b: BlockPath): boolean {
|
||||
if (a.length === 0 || b.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const aLast = a[a.length - 1];
|
||||
const bLast = b[b.length - 1];
|
||||
if (aLast.list !== bLast.list) {
|
||||
return false;
|
||||
}
|
||||
if (a.length === 1 && b.length === 1) {
|
||||
return aLast.list === 'root' && bLast.list === 'root';
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return parentPathKey(a) === parentPathKey(b);
|
||||
}
|
||||
|
||||
export function moveBlockAt(root: MmBlock[], fromPath: BlockPath, toIndex: number): MmBlock[] {
|
||||
const ctx = getParentContext(root, fromPath);
|
||||
if (!ctx || toIndex < 0 || toIndex >= ctx.list.length) {
|
||||
return root;
|
||||
}
|
||||
const fromIndex = ctx.index;
|
||||
if (fromIndex === toIndex) {
|
||||
return root;
|
||||
}
|
||||
const draft = cloneBlocks(root);
|
||||
const draftCtx = getParentContext(draft, fromPath);
|
||||
if (!draftCtx) {
|
||||
return draft;
|
||||
}
|
||||
const [item] = draftCtx.list.splice(draftCtx.index, 1);
|
||||
draftCtx.list.splice(toIndex, 0, item);
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Updates a path after a sibling reorder in the same list. */
|
||||
export function remapPathAfterMove(path: BlockPath, fromPath: BlockPath, toIndex: number): BlockPath {
|
||||
if (!sameParentList(path, fromPath)) {
|
||||
return path;
|
||||
}
|
||||
const fromIndex = fromPath[fromPath.length - 1].index;
|
||||
const last = path[path.length - 1];
|
||||
|
||||
if (pathKey(path) === pathKey(fromPath)) {
|
||||
return [...path.slice(0, -1), {list: last.list, index: toIndex}];
|
||||
}
|
||||
|
||||
const pathIndex = last.index;
|
||||
if (fromIndex < pathIndex && pathIndex <= toIndex) {
|
||||
return [...path.slice(0, -1), {list: last.list, index: pathIndex - 1}];
|
||||
}
|
||||
if (toIndex <= pathIndex && pathIndex < fromIndex) {
|
||||
return [...path.slice(0, -1), {list: last.list, index: pathIndex + 1}];
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export const MM_BLOCKS_DRAG_MIME = 'application/x-mm-block-path';
|
||||
|
||||
export function listLabel(list: ChildListKey): string | null {
|
||||
switch (list) {
|
||||
case 'content':
|
||||
return 'content';
|
||||
case 'items':
|
||||
return 'items';
|
||||
case 'columns':
|
||||
return 'columns';
|
||||
case 'header':
|
||||
return 'header';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function childPaths(block: MmBlock | MmColumnBlock, parentPath: BlockPath): BlockPath[] {
|
||||
const paths: BlockPath[] = [];
|
||||
const appendChildren = (list: ChildListKey, items: Array<MmBlock | MmColumnBlock>) => {
|
||||
items.forEach((_, index) => {
|
||||
paths.push([...parentPath, {list, index}]);
|
||||
});
|
||||
};
|
||||
|
||||
switch (block.type) {
|
||||
case 'container':
|
||||
appendChildren('content', block.content);
|
||||
break;
|
||||
case 'column':
|
||||
appendChildren('items', block.items);
|
||||
break;
|
||||
case 'column_set':
|
||||
appendChildren('columns', block.columns);
|
||||
break;
|
||||
case 'collapsible':
|
||||
appendChildren('header', block.header);
|
||||
appendChildren('content', block.content);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function blockSummary(block: MmBlock | MmColumnBlock): string {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
const preview = block.text.replace(/\s+/g, ' ').trim();
|
||||
const truncated = preview.length > 40 ? `${preview.slice(0, 40)}…` : preview;
|
||||
return truncated || '(empty text)';
|
||||
}
|
||||
case 'button':
|
||||
return block.text || block.action_id;
|
||||
case 'static_select':
|
||||
return block.placeholder || block.action_id;
|
||||
case 'image':
|
||||
return block.alt_text || block.url;
|
||||
case 'container':
|
||||
return `${block.content.length} item${block.content.length === 1 ? '' : 's'}`;
|
||||
case 'column':
|
||||
return `${block.items.length} item${block.items.length === 1 ? '' : 's'}`;
|
||||
case 'column_set':
|
||||
return `${block.columns.length} column${block.columns.length === 1 ? '' : 's'}`;
|
||||
case 'collapsible':
|
||||
return 'Collapsible section';
|
||||
case 'divider':
|
||||
return 'Horizontal rule';
|
||||
default:
|
||||
return 'Unknown block';
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_SIZE_OPTIONS: MmTextSize[] = ['small', 'default'];
|
||||
const BUTTON_STYLE_OPTIONS: MmButtonStyle[] = ['default', 'primary', 'danger', 'good', 'success', 'warning'];
|
||||
const IMAGE_SIZE_OPTIONS: MmImageSize[] = ['auto', 'xsmall', 'small', 'medium', 'large', 'stretch'];
|
||||
const CONTAINER_GAP_OPTIONS: MmContainerGap[] = ['none', 'small', 'medium', 'large', 'xlarge'];
|
||||
const CONTAINER_BACKGROUND_OPTIONS: MmContainerBackground[] = ['none', 'gray'];
|
||||
const CONTAINER_MAX_HEIGHT_OPTIONS: MmContainerMaxHeight[] = ['none', 'small', 'medium', 'large'];
|
||||
|
||||
export function propertyFieldsForBlock(block: MmBlock | MmColumnBlock): PropertyField[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [
|
||||
{key: 'text', label: 'text', type: 'string', placeholder: 'Markdown text'},
|
||||
{key: 'is_subtle', label: 'is_subtle', type: 'boolean'},
|
||||
{key: 'size', label: 'size', type: 'enum', options: TEXT_SIZE_OPTIONS},
|
||||
];
|
||||
case 'divider':
|
||||
return [];
|
||||
case 'button':
|
||||
return [
|
||||
{key: 'text', label: 'text', type: 'string'},
|
||||
{key: 'action_id', label: 'action_id', type: 'string'},
|
||||
{key: 'style', label: 'style', type: 'enum', options: BUTTON_STYLE_OPTIONS},
|
||||
{key: 'tooltip', label: 'tooltip', type: 'string'},
|
||||
{key: 'disabled', label: 'disabled', type: 'boolean'},
|
||||
{key: 'query', label: 'query', type: 'json'},
|
||||
{key: 'cookie', label: 'cookie', type: 'string'},
|
||||
];
|
||||
case 'static_select':
|
||||
return [
|
||||
{key: 'action_id', label: 'action_id', type: 'string'},
|
||||
{key: 'placeholder', label: 'placeholder', type: 'string'},
|
||||
{key: 'options', label: 'options', type: 'json'},
|
||||
{key: 'initial_option', label: 'initial_option', type: 'string'},
|
||||
{key: 'disabled', label: 'disabled', type: 'boolean'},
|
||||
{key: 'data_source', label: 'data_source', type: 'string'},
|
||||
{key: 'query', label: 'query', type: 'json'},
|
||||
{key: 'cookie', label: 'cookie', type: 'string'},
|
||||
];
|
||||
case 'image':
|
||||
return [
|
||||
{key: 'url', label: 'url', type: 'string'},
|
||||
{key: 'alt_text', label: 'alt_text', type: 'string'},
|
||||
{key: 'title', label: 'title', type: 'string'},
|
||||
{key: 'size', label: 'size', type: 'enum', options: IMAGE_SIZE_OPTIONS},
|
||||
{key: 'max_width', label: 'max_width', type: 'number'},
|
||||
{key: 'max_height', label: 'max_height', type: 'number'},
|
||||
{key: 'image_style', label: 'image_style', type: 'enum', options: ['default', 'person']},
|
||||
{key: 'horizontal_alignment', label: 'horizontal_alignment', type: 'enum', options: ['left', 'center', 'right']},
|
||||
];
|
||||
case 'column':
|
||||
return [
|
||||
{key: 'width', label: 'width', type: 'enum', options: ['auto', 'stretch']},
|
||||
];
|
||||
case 'container':
|
||||
return [
|
||||
{key: 'border', label: 'border', type: 'boolean'},
|
||||
{key: 'accent_color', label: 'accent_color', type: 'string', placeholder: 'Semantic or #hex'},
|
||||
{key: 'flow', label: 'flow', type: 'enum', options: ['horizontal', 'vertical']},
|
||||
{key: 'gap', label: 'gap', type: 'enum', options: CONTAINER_GAP_OPTIONS},
|
||||
{key: 'background', label: 'background', type: 'enum', options: CONTAINER_BACKGROUND_OPTIONS},
|
||||
{key: 'max_height', label: 'max_height', type: 'enum', options: CONTAINER_MAX_HEIGHT_OPTIONS},
|
||||
];
|
||||
case 'collapsible':
|
||||
return [
|
||||
{key: 'collapsed', label: 'collapsed', type: 'boolean'},
|
||||
];
|
||||
case 'column_set':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPropertyValue(block: MmBlock | MmColumnBlock, key: string): unknown {
|
||||
return (block as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
export function setPropertyValue(
|
||||
block: MmBlock | MmColumnBlock,
|
||||
key: string,
|
||||
raw: string,
|
||||
field: PropertyField,
|
||||
): MmBlock | MmColumnBlock {
|
||||
const next = {...block} as Record<string, unknown>;
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
if (raw === '' || raw === 'false') {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = true;
|
||||
}
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (raw.trim() === '') {
|
||||
delete next[key];
|
||||
} else {
|
||||
const num = Number(raw);
|
||||
if (Number.isFinite(num)) {
|
||||
next[key] = num;
|
||||
}
|
||||
}
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
|
||||
if (field.type === 'enum') {
|
||||
if (raw === '') {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = raw;
|
||||
}
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
if (raw.trim() === '') {
|
||||
delete next[key];
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (key === 'options' && Array.isArray(parsed)) {
|
||||
next[key] = parsed as MmStaticSelectOption[];
|
||||
} else if (key === 'query' && typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
next[key] = parsed as Record<string, string>;
|
||||
} else {
|
||||
next[key] = parsed;
|
||||
}
|
||||
} catch {
|
||||
return block;
|
||||
}
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
|
||||
if (raw.trim() === '') {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = raw;
|
||||
}
|
||||
return next as MmBlock | MmColumnBlock;
|
||||
}
|
||||
|
||||
export function formatPropertyValue(value: unknown, field: PropertyField): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return value === true ? 'true' : '';
|
||||
}
|
||||
if (field.type === 'json') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
.MmBlocksHierarchyEditor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
max-width: 56rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__treePanel,
|
||||
.MmBlocksHierarchyEditor__propertiesPanel {
|
||||
flex: 1 1 240px;
|
||||
min-width: 220px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__heading {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__emptyRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__emptyAdd {
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__tree,
|
||||
.MmBlocksHierarchyEditor__children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__listLabel {
|
||||
margin: 6px 0 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__node {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
|
||||
.MmBlocksHierarchyEditor__rowActions,
|
||||
.MmBlocksHierarchyEditor__dragHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__rowActions {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: rgba(var(--button-bg-rgb), 0.14);
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
&.is-drag-over {
|
||||
background: rgba(var(--button-bg-rgb), 0.18);
|
||||
outline: 1px dashed var(--button-bg);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__dragHandle {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.44);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--button-bg);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__rowButton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--button-bg);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__type {
|
||||
display: inline-block;
|
||||
min-width: 5.5rem;
|
||||
margin-right: 6px;
|
||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__summary {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__rowActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__addMenu {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 10rem;
|
||||
padding: 4px 0;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
background: var(--center-channel-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__addMenuHeading {
|
||||
padding: 6px 12px 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__addMenuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-bg-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__propertyRow {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
&--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.24);
|
||||
border-radius: 3px;
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__propertyLabel {
|
||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.8);
|
||||
}
|
||||
|
||||
.MmBlocksHierarchyEditor__propertyJson {
|
||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.clMmBlocksEditorLayout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
max-width: 72rem;
|
||||
}
|
||||
|
||||
.clMmBlocksEditorLayout__json {
|
||||
flex: 1 1 280px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.clMmBlocksEditorLayout__json .clJsonEditor {
|
||||
max-width: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable formatjs/no-literal-string-in-jsx -- component library dev playground */
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {Button} from '@mattermost/shared/components/button';
|
||||
import type {MmBlock, MmColumnBlock} from '@mattermost/types/mm_blocks';
|
||||
|
||||
import {
|
||||
type AddBlockTarget,
|
||||
type BlockPath,
|
||||
type BlockTypeId,
|
||||
type ChildListKey,
|
||||
MM_BLOCKS_DRAG_MIME,
|
||||
ROOT_ADDABLE_TYPES,
|
||||
addableTypesForList,
|
||||
blockSummary,
|
||||
blockTypeLabel,
|
||||
canAddChild,
|
||||
childPaths,
|
||||
createDefaultBlock,
|
||||
formatPropertyValue,
|
||||
getBlockAt,
|
||||
getPropertyValue,
|
||||
insertBlockAt,
|
||||
listLabel,
|
||||
moveBlockAt,
|
||||
parsePathKey,
|
||||
pathKey,
|
||||
propertyFieldsForBlock,
|
||||
remapPathAfterMove,
|
||||
removeBlockAt,
|
||||
sameParentList,
|
||||
setPropertyValue,
|
||||
type PropertyField,
|
||||
updateBlockAt,
|
||||
} from './mm_blocks_editor_utils';
|
||||
|
||||
import './mm_blocks_hierarchy_editor.scss';
|
||||
|
||||
type Props = {
|
||||
blocks: MmBlock[];
|
||||
selectedPath: BlockPath | null;
|
||||
onSelectPath: (path: BlockPath | null) => void;
|
||||
onChangeBlocks: (blocks: MmBlock[]) => void;
|
||||
};
|
||||
|
||||
type AddMenuState = {
|
||||
path: BlockPath;
|
||||
};
|
||||
|
||||
const PropertyFieldEditor = ({
|
||||
block,
|
||||
field,
|
||||
onChange,
|
||||
}: {
|
||||
block: MmBlock | MmColumnBlock;
|
||||
field: PropertyField;
|
||||
onChange: (next: MmBlock | MmColumnBlock) => void;
|
||||
}) => {
|
||||
const value = getPropertyValue(block, field.key);
|
||||
const display = formatPropertyValue(value, field);
|
||||
const id = `mm-block-prop-${field.key}`;
|
||||
|
||||
const onStringChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onChange(setPropertyValue(block, field.key, e.target.value, field));
|
||||
}, [block, field, onChange]);
|
||||
|
||||
const onBooleanChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(setPropertyValue(block, field.key, e.target.checked ? 'true' : '', field));
|
||||
}, [block, field, onChange]);
|
||||
|
||||
const onEnumChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange(setPropertyValue(block, field.key, e.target.value, field));
|
||||
}, [block, field, onChange]);
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<label className='MmBlocksHierarchyEditor__propertyRow'>
|
||||
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
|
||||
<input
|
||||
id={id}
|
||||
type='checkbox'
|
||||
checked={value === true}
|
||||
onChange={onBooleanChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'enum' && field.options) {
|
||||
return (
|
||||
<label className='MmBlocksHierarchyEditor__propertyRow'>
|
||||
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
|
||||
<select
|
||||
id={id}
|
||||
value={display}
|
||||
onChange={onEnumChange}
|
||||
>
|
||||
<option value=''>{'(unset)'}</option>
|
||||
{field.options.map((opt) => (
|
||||
<option
|
||||
key={opt}
|
||||
value={opt}
|
||||
>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
return (
|
||||
<label className='MmBlocksHierarchyEditor__propertyRow MmBlocksHierarchyEditor__propertyRow--stacked'>
|
||||
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
|
||||
<textarea
|
||||
id={id}
|
||||
className='MmBlocksHierarchyEditor__propertyJson'
|
||||
spellCheck={false}
|
||||
rows={4}
|
||||
value={display}
|
||||
placeholder={field.placeholder ?? 'JSON'}
|
||||
onChange={onStringChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const isLong = field.key === 'text';
|
||||
return (
|
||||
<label
|
||||
className={classNames('MmBlocksHierarchyEditor__propertyRow', {
|
||||
'MmBlocksHierarchyEditor__propertyRow--stacked': isLong,
|
||||
})}
|
||||
>
|
||||
<span className='MmBlocksHierarchyEditor__propertyLabel'>{field.label}</span>
|
||||
{isLong ? (
|
||||
<textarea
|
||||
id={id}
|
||||
className='MmBlocksHierarchyEditor__propertyJson'
|
||||
spellCheck={false}
|
||||
rows={3}
|
||||
value={display}
|
||||
placeholder={field.placeholder}
|
||||
onChange={onStringChange}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={display}
|
||||
placeholder={field.placeholder}
|
||||
onChange={onStringChange}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const AddBlockMenu = ({
|
||||
addableTypes,
|
||||
childAddableTypes,
|
||||
onPick,
|
||||
onPickChild,
|
||||
onClose,
|
||||
}: {
|
||||
addableTypes: BlockTypeId[];
|
||||
childAddableTypes?: BlockTypeId[];
|
||||
onPick: (type: BlockTypeId) => void;
|
||||
onPickChild?: (type: BlockTypeId) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className='MmBlocksHierarchyEditor__addMenu'
|
||||
role='menu'
|
||||
>
|
||||
{childAddableTypes && onPickChild && childAddableTypes.length > 0 && (
|
||||
<>
|
||||
<div className='MmBlocksHierarchyEditor__addMenuHeading'>{'Inside block'}</div>
|
||||
{childAddableTypes.map((type) => (
|
||||
<button
|
||||
key={`child-${type}`}
|
||||
type='button'
|
||||
className='MmBlocksHierarchyEditor__addMenuItem'
|
||||
role='menuitem'
|
||||
onClick={() => onPickChild(type)}
|
||||
>
|
||||
{blockTypeLabel(type)}
|
||||
</button>
|
||||
))}
|
||||
<div className='MmBlocksHierarchyEditor__addMenuHeading'>{'After block'}</div>
|
||||
</>
|
||||
)}
|
||||
{addableTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type='button'
|
||||
className='MmBlocksHierarchyEditor__addMenuItem'
|
||||
role='menuitem'
|
||||
onClick={() => onPick(type)}
|
||||
>
|
||||
{blockTypeLabel(type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type HierarchyNodeProps = {
|
||||
root: MmBlock[];
|
||||
block: MmBlock | MmColumnBlock;
|
||||
path: BlockPath;
|
||||
depth: number;
|
||||
selectedPath: BlockPath | null;
|
||||
draggingPath: BlockPath | null;
|
||||
dragOverPath: BlockPath | null;
|
||||
addMenu: AddMenuState | null;
|
||||
onSelectPath: (path: BlockPath) => void;
|
||||
onOpenAddMenu: (state: AddMenuState) => void;
|
||||
onCloseAddMenu: () => void;
|
||||
onAddBlock: (path: BlockPath, type: BlockTypeId, target: AddBlockTarget) => void;
|
||||
onRemoveBlock: (path: BlockPath) => void;
|
||||
onDragStart: (path: BlockPath) => void;
|
||||
onDragOver: (path: BlockPath) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (fromPath: BlockPath, toIndex: number) => void;
|
||||
};
|
||||
|
||||
const HierarchyNode = ({
|
||||
root,
|
||||
block,
|
||||
path,
|
||||
depth,
|
||||
selectedPath,
|
||||
draggingPath,
|
||||
dragOverPath,
|
||||
addMenu,
|
||||
onSelectPath,
|
||||
onOpenAddMenu,
|
||||
onCloseAddMenu,
|
||||
onAddBlock,
|
||||
onRemoveBlock,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
}: HierarchyNodeProps) => {
|
||||
const key = pathKey(path);
|
||||
const isSelected = selectedPath !== null && pathKey(selectedPath) === key;
|
||||
const isDragging = draggingPath !== null && pathKey(draggingPath) === key;
|
||||
const isDragOver = dragOverPath !== null && pathKey(dragOverPath) === key && !isDragging;
|
||||
const canDrop = draggingPath !== null && sameParentList(draggingPath, path);
|
||||
const parentList = path[path.length - 1]?.list ?? 'root';
|
||||
const supportsChild = canAddChild(block);
|
||||
const menuOpen = addMenu !== null && pathKey(addMenu.path) === key;
|
||||
|
||||
const childListKey = useMemo((): ChildListKey | null => {
|
||||
switch (block.type) {
|
||||
case 'container':
|
||||
return 'content';
|
||||
case 'column':
|
||||
return 'items';
|
||||
case 'column_set':
|
||||
return 'columns';
|
||||
case 'collapsible':
|
||||
return 'content';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [block.type]);
|
||||
|
||||
const siblingTypes = addableTypesForList(parentList);
|
||||
const childTypes = supportsChild && childListKey ? addableTypesForList(childListKey) : [];
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
onSelectPath(path);
|
||||
}, [onSelectPath, path]);
|
||||
|
||||
const onRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemoveBlock(path);
|
||||
}, [onRemoveBlock, path]);
|
||||
|
||||
const onAdd = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onOpenAddMenu({path});
|
||||
}, [onOpenAddMenu, path]);
|
||||
|
||||
const onHandleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData(MM_BLOCKS_DRAG_MIME, key);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
onDragStart(path);
|
||||
}, [key, onDragStart, path]);
|
||||
|
||||
const onRowDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!draggingPath || !sameParentList(draggingPath, path)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
onDragOver(path);
|
||||
}, [draggingPath, onDragOver, path]);
|
||||
|
||||
const onRowDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fromKey = e.dataTransfer.getData(MM_BLOCKS_DRAG_MIME);
|
||||
const fromPath = parsePathKey(fromKey);
|
||||
if (!fromPath || !sameParentList(fromPath, path)) {
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
onDrop(fromPath, path[path.length - 1].index);
|
||||
onDragEnd();
|
||||
}, [onDragEnd, onDrop, path]);
|
||||
|
||||
const childPathList = useMemo(() => childPaths(block, path), [block, path]);
|
||||
|
||||
const childGroups = useMemo(() => {
|
||||
const groups: Array<{list: ChildListKey; label: string | null; paths: BlockPath[]}> = [];
|
||||
for (const childPath of childPathList) {
|
||||
const segment = childPath[childPath.length - 1];
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.list === segment.list) {
|
||||
last.paths.push(childPath);
|
||||
} else {
|
||||
groups.push({
|
||||
list: segment.list,
|
||||
label: listLabel(segment.list),
|
||||
paths: [childPath],
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [childPathList]);
|
||||
|
||||
return (
|
||||
<li className='MmBlocksHierarchyEditor__node'>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- drop target for reorder */}
|
||||
<div
|
||||
className={classNames('MmBlocksHierarchyEditor__row', {
|
||||
'is-selected': isSelected,
|
||||
'is-dragging': isDragging,
|
||||
'is-drag-over': isDragOver,
|
||||
'can-drop': canDrop,
|
||||
})}
|
||||
style={{paddingLeft: `${(depth * 12) + 8}px`}}
|
||||
onDragOver={onRowDragOver}
|
||||
onDrop={onRowDrop}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='MmBlocksHierarchyEditor__dragHandle'
|
||||
draggable={true}
|
||||
onDragStart={onHandleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
aria-label='Drag to reorder'
|
||||
title='Drag to reorder'
|
||||
>
|
||||
{'⠿'}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='MmBlocksHierarchyEditor__rowButton'
|
||||
onClick={onSelect}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className='MmBlocksHierarchyEditor__type'>{block.type}</span>
|
||||
<span className='MmBlocksHierarchyEditor__summary'>{blockSummary(block)}</span>
|
||||
</button>
|
||||
<div className='MmBlocksHierarchyEditor__rowActions'>
|
||||
<Button
|
||||
size='xs'
|
||||
emphasis='quaternary'
|
||||
onClick={onAdd}
|
||||
aria-label='Add block'
|
||||
>
|
||||
{'+'}
|
||||
</Button>
|
||||
<Button
|
||||
size='xs'
|
||||
emphasis='quaternary'
|
||||
onClick={onRemove}
|
||||
aria-label='Remove block'
|
||||
>
|
||||
{'×'}
|
||||
</Button>
|
||||
</div>
|
||||
{menuOpen && addMenu && pathKey(addMenu.path) === key && (
|
||||
<AddBlockMenu
|
||||
addableTypes={siblingTypes}
|
||||
childAddableTypes={childTypes}
|
||||
onPick={(type) => {
|
||||
onAddBlock(addMenu.path, type, 'sibling');
|
||||
onCloseAddMenu();
|
||||
}}
|
||||
onPickChild={childTypes.length > 0 ? (type) => {
|
||||
onAddBlock(addMenu.path, type, 'child');
|
||||
onCloseAddMenu();
|
||||
} : undefined}
|
||||
onClose={onCloseAddMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{childGroups.length > 0 && (
|
||||
<ul className='MmBlocksHierarchyEditor__children'>
|
||||
{childGroups.map((group) => (
|
||||
<React.Fragment key={`${key}/${group.list}`}>
|
||||
{group.label && (
|
||||
<li
|
||||
className='MmBlocksHierarchyEditor__listLabel'
|
||||
style={{paddingLeft: `${((depth + 1) * 12) + 8}px`}}
|
||||
>
|
||||
{group.label}
|
||||
</li>
|
||||
)}
|
||||
{group.paths.map((childPath) => {
|
||||
const childBlock = getBlockAt(root, childPath);
|
||||
if (!childBlock) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HierarchyNode
|
||||
key={pathKey(childPath)}
|
||||
root={root}
|
||||
block={childBlock}
|
||||
path={childPath}
|
||||
depth={depth + 1}
|
||||
selectedPath={selectedPath}
|
||||
draggingPath={draggingPath}
|
||||
dragOverPath={dragOverPath}
|
||||
addMenu={addMenu}
|
||||
onSelectPath={onSelectPath}
|
||||
onOpenAddMenu={onOpenAddMenu}
|
||||
onCloseAddMenu={onCloseAddMenu}
|
||||
onAddBlock={onAddBlock}
|
||||
onRemoveBlock={onRemoveBlock}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const MmBlocksHierarchyEditor = ({
|
||||
blocks,
|
||||
selectedPath,
|
||||
onSelectPath,
|
||||
onChangeBlocks,
|
||||
}: Props) => {
|
||||
const [addMenu, setAddMenu] = useState<AddMenuState | null>(null);
|
||||
const [rootAddOpen, setRootAddOpen] = useState(false);
|
||||
const [draggingPath, setDraggingPath] = useState<BlockPath | null>(null);
|
||||
const [dragOverPath, setDragOverPath] = useState<BlockPath | null>(null);
|
||||
|
||||
const selectedBlock = useMemo(() => {
|
||||
if (!selectedPath) {
|
||||
return null;
|
||||
}
|
||||
return getBlockAt(blocks, selectedPath);
|
||||
}, [blocks, selectedPath]);
|
||||
|
||||
const propertyFields = useMemo(() => {
|
||||
if (!selectedBlock) {
|
||||
return [];
|
||||
}
|
||||
return propertyFieldsForBlock(selectedBlock);
|
||||
}, [selectedBlock]);
|
||||
|
||||
const onCloseAddMenu = useCallback(() => {
|
||||
setAddMenu(null);
|
||||
}, []);
|
||||
|
||||
const onAddBlock = useCallback((path: BlockPath, type: BlockTypeId, target: AddBlockTarget) => {
|
||||
const newBlock = createDefaultBlock(type);
|
||||
const next = insertBlockAt(blocks, path, newBlock, target);
|
||||
onChangeBlocks(next);
|
||||
if (target === 'child') {
|
||||
const parent = getBlockAt(next, path);
|
||||
if (!parent || !canAddChild(parent)) {
|
||||
return;
|
||||
}
|
||||
let childList: ChildListKey = 'content';
|
||||
let listLength = 0;
|
||||
if (parent.type === 'container') {
|
||||
childList = 'content';
|
||||
listLength = parent.content.length;
|
||||
} else if (parent.type === 'column') {
|
||||
childList = 'items';
|
||||
listLength = parent.items.length;
|
||||
} else if (parent.type === 'column_set') {
|
||||
childList = 'columns';
|
||||
listLength = parent.columns.length;
|
||||
} else if (parent.type === 'collapsible') {
|
||||
childList = 'content';
|
||||
listLength = parent.content.length;
|
||||
}
|
||||
onSelectPath([...path, {list: childList, index: listLength - 1}]);
|
||||
}
|
||||
}, [blocks, onChangeBlocks, onSelectPath]);
|
||||
|
||||
const onRemoveBlock = useCallback((path: BlockPath) => {
|
||||
const next = removeBlockAt(blocks, path);
|
||||
onChangeBlocks(next);
|
||||
if (selectedPath && pathKey(selectedPath) === pathKey(path)) {
|
||||
onSelectPath(null);
|
||||
}
|
||||
}, [blocks, onChangeBlocks, onSelectPath, selectedPath]);
|
||||
|
||||
const onDragEnd = useCallback(() => {
|
||||
setDraggingPath(null);
|
||||
setDragOverPath(null);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((fromPath: BlockPath, toIndex: number) => {
|
||||
const next = moveBlockAt(blocks, fromPath, toIndex);
|
||||
onChangeBlocks(next);
|
||||
if (selectedPath) {
|
||||
onSelectPath(remapPathAfterMove(selectedPath, fromPath, toIndex));
|
||||
}
|
||||
}, [blocks, onChangeBlocks, onSelectPath, selectedPath]);
|
||||
|
||||
const onPropertyChange = useCallback((next: MmBlock | MmColumnBlock) => {
|
||||
if (!selectedPath) {
|
||||
return;
|
||||
}
|
||||
onChangeBlocks(updateBlockAt(blocks, selectedPath, next));
|
||||
}, [blocks, onChangeBlocks, selectedPath]);
|
||||
|
||||
const rootPaths = useMemo(
|
||||
() => blocks.map((_, index): BlockPath => [{list: 'root', index}]),
|
||||
[blocks],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='MmBlocksHierarchyEditor'>
|
||||
<div className='MmBlocksHierarchyEditor__treePanel'>
|
||||
<h3 className='MmBlocksHierarchyEditor__heading'>{'Block hierarchy'}</h3>
|
||||
{blocks.length === 0 ? (
|
||||
<div className='MmBlocksHierarchyEditor__emptyRoot'>
|
||||
<p className='MmBlocksHierarchyEditor__empty'>{'No blocks yet.'}</p>
|
||||
<div className='MmBlocksHierarchyEditor__emptyAdd'>
|
||||
<Button
|
||||
size='xs'
|
||||
onClick={() => setRootAddOpen((open) => !open)}
|
||||
>
|
||||
{'Add block'}
|
||||
</Button>
|
||||
{rootAddOpen && (
|
||||
<AddBlockMenu
|
||||
addableTypes={ROOT_ADDABLE_TYPES}
|
||||
onPick={(type) => {
|
||||
onChangeBlocks([createDefaultBlock(type)]);
|
||||
setRootAddOpen(false);
|
||||
}}
|
||||
onClose={() => setRootAddOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className='MmBlocksHierarchyEditor__tree'>
|
||||
{rootPaths.map((path, i) => {
|
||||
const block = blocks[i];
|
||||
if (!block) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HierarchyNode
|
||||
key={pathKey(path)}
|
||||
root={blocks}
|
||||
block={block}
|
||||
path={path}
|
||||
depth={0}
|
||||
selectedPath={selectedPath}
|
||||
draggingPath={draggingPath}
|
||||
dragOverPath={dragOverPath}
|
||||
addMenu={addMenu}
|
||||
onSelectPath={onSelectPath}
|
||||
onOpenAddMenu={setAddMenu}
|
||||
onCloseAddMenu={onCloseAddMenu}
|
||||
onAddBlock={onAddBlock}
|
||||
onRemoveBlock={onRemoveBlock}
|
||||
onDragStart={setDraggingPath}
|
||||
onDragOver={setDragOverPath}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className='MmBlocksHierarchyEditor__propertiesPanel'>
|
||||
<h3 className='MmBlocksHierarchyEditor__heading'>{'Properties'}</h3>
|
||||
{!selectedBlock && (
|
||||
<p className='MmBlocksHierarchyEditor__empty'>{'Select a block to edit its properties.'}</p>
|
||||
)}
|
||||
{selectedBlock && propertyFields.length === 0 && (
|
||||
<p className='MmBlocksHierarchyEditor__empty'>
|
||||
{`Block type "${selectedBlock.type}" has no editable scalar properties. Edit nested blocks in the hierarchy.`}
|
||||
</p>
|
||||
)}
|
||||
{selectedBlock && propertyFields.length > 0 && (
|
||||
<div className='MmBlocksHierarchyEditor__properties'>
|
||||
{propertyFields.map((field) => (
|
||||
<PropertyFieldEditor
|
||||
key={field.key}
|
||||
block={selectedBlock}
|
||||
field={field}
|
||||
onChange={onPropertyChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MmBlocksHierarchyEditor;
|
||||
Loading…
Reference in a new issue