diff --git a/src/App.css b/src/App.css index 65051ee..5d716dc 100644 --- a/src/App.css +++ b/src/App.css @@ -159,6 +159,7 @@ body, } .block { + position: relative; margin: 0 -8px; padding: 0 8px; border-radius: 4px; @@ -169,6 +170,42 @@ body, outline-color: #3b82f6; } +/* Sits in the Page's left margin; wide enough to bridge the gap so hover persists. */ +.block-handle { + position: absolute; + top: 50%; + right: 100%; + transform: translateY(-50%); + display: none; + padding-right: 4px; +} + +.block:hover > .block-handle, +.block-selected > .block-handle, +.block-handle:focus-within { + display: flex; +} + +.block-delete { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: #777; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.block-delete:hover, +.block-delete:focus-visible { + color: #b91c1c; + background: rgba(185, 28, 28, 0.1); +} + .properties-panel .field { display: flex; flex-direction: column; diff --git a/src/components/Worksheet.tsx b/src/components/Worksheet.tsx index f22ac0b..2807647 100644 --- a/src/components/Worksheet.tsx +++ b/src/components/Worksheet.tsx @@ -10,9 +10,16 @@ export function Worksheet() { const { state, dispatch } = useWorksheet(); const insertionIndex = useInsertionIndex(); const { setNodeRef } = useDroppable({ id: WORKSHEET_DROPPABLE_ID }); + const { selectedId } = state; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Delete' || event.key === 'Backspace') { + if (selectedId === null || isTextEntry(document.activeElement)) return; + event.preventDefault(); + dispatch({ type: 'remove', id: selectedId }); + return; + } if (event.key !== 'Escape') return; if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); @@ -21,7 +28,7 @@ export function Worksheet() { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [dispatch]); + }, [dispatch, selectedId]); return (
+ element?.closest('input, textarea, select, [contenteditable]') != null; + function InsertionLine() { return
; } @@ -84,6 +94,31 @@ function BlockHost({ onClick={select} onFocus={select} > +
+ +
+ + + + + , + ); +} + +const blocks = () => Array.from(document.querySelectorAll('.block')); +const insert = (name: string) => + userEvent.click(screen.getByRole('button', { name })); +const hint = () => screen.queryByText('Drag a Block here from the left'); + +/** Moves focus out of the Block's text while keeping the Block selected. */ +const blurText = () => (document.activeElement as HTMLElement).blur(); + +describe('deleting Blocks', () => { + it('removes a Block with the delete button on its handle', async () => { + setup(); + await insert('Heading'); + await insert('Body Text'); + + await userEvent.click( + blocks()[0].querySelector('[aria-label="Delete Block"]')!, + ); + + expect(blocks()).toHaveLength(1); + expect(blocks()[0].querySelector('.heading')).toBeNull(); + }); + + it('shows the empty-Worksheet hint again after deleting the last Block', async () => { + setup(); + await insert('Heading'); + expect(hint()).toBeNull(); + + await userEvent.click(screen.getByRole('button', { name: 'Delete Block' })); + + expect(hint()).not.toBeNull(); + }); + + it.each(['{Delete}', '{Backspace}'])( + 'removes the selected Block on %s when its text is not focused', + async (key) => { + setup(); + await insert('Heading'); + blurText(); + + await userEvent.keyboard(key); + + expect(blocks()).toHaveLength(0); + }, + ); + + it.each(['{Delete}', '{Backspace}'])( + 'never removes the Block on %s while typing in its text', + async (key) => { + setup(); + await insert('Heading'); + await waitFor(() => + expect(blocks()[0].querySelector('.heading')).toHaveFocus(), + ); + + await userEvent.keyboard(key); + + expect(blocks()).toHaveLength(1); + }, + ); +}); diff --git a/src/worksheet/reducer.test.ts b/src/worksheet/reducer.test.ts index 607014e..fbb4778 100644 --- a/src/worksheet/reducer.test.ts +++ b/src/worksheet/reducer.test.ts @@ -158,3 +158,49 @@ describe('worksheetReducer selection', () => { expect(state.blocks.map((b) => b.id)).toEqual([...ids, state.selectedId]); }); }); + +describe('worksheetReducer remove', () => { + const twoBlocks = () => { + let state = initialState; + state = worksheetReducer(state, { type: 'insert', blockType: 'heading' }); + state = worksheetReducer(state, { type: 'insert', blockType: 'heading' }); + return state; + }; + + it('removes the Block', () => { + const state = twoBlocks(); + const [first, second] = state.blocks; + + const next = worksheetReducer(state, { type: 'remove', id: first.id }); + + expect(next.blocks.map((b) => b.id)).toEqual([second.id]); + }); + + it('clears the selection when removing the selected Block', () => { + const state = twoBlocks(); + + const next = worksheetReducer(state, { + type: 'remove', + id: state.selectedId!, + }); + + expect(next.selectedId).toBeNull(); + }); + + it('keeps the selection when removing another Block', () => { + const state = twoBlocks(); + + const next = worksheetReducer(state, { + type: 'remove', + id: state.blocks[0].id, + }); + + expect(next.selectedId).toBe(state.selectedId); + }); + + it('leaves the state unchanged for an unknown id', () => { + const state = twoBlocks(); + + expect(worksheetReducer(state, { type: 'remove', id: 'nope' })).toBe(state); + }); +}); diff --git a/src/worksheet/reducer.ts b/src/worksheet/reducer.ts index 9d74e8d..1bfb7cf 100644 --- a/src/worksheet/reducer.ts +++ b/src/worksheet/reducer.ts @@ -13,6 +13,7 @@ export type WorksheetState = { export type WorksheetAction = | { type: 'insert'; blockType: string; index?: number } | { type: 'update'; id: string; data: unknown } + | { type: 'remove'; id: string } | { type: 'select'; id: string } | { type: 'deselect' }; @@ -56,6 +57,15 @@ export const createWorksheetReducer = nextId: state.nextId + 1, }; } + case 'remove': + return state.blocks.some((b) => b.id === action.id) + ? { + ...state, + blocks: state.blocks.filter((b) => b.id !== action.id), + selectedId: + state.selectedId === action.id ? null : state.selectedId, + } + : state; case 'select': return state.blocks.some((b) => b.id === action.id) ? { ...state, selectedId: action.id }