From e7eaa7a6019e7647f90dbe1dd4db2d6d8327eb9b Mon Sep 17 00:00:00 2001 From: Marian Hahne Date: Tue, 20 Oct 2026 12:41:01 +0200 Subject: [PATCH 1/3] feat(app): delete Blocks (#5) Co-Authored-By: Claude Sonnet 5.5 --- src/App.css | 34 ++++++++++++ src/components/Worksheet.tsx | 25 ++++++++- src/components/delete-block.test.tsx | 80 ++++++++++++++++++++++++++++ src/worksheet/reducer.test.ts | 46 ++++++++++++++++ src/worksheet/reducer.ts | 10 ++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/components/delete-block.test.tsx diff --git a/src/App.css b/src/App.css index 65051ee..49df1e6 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,39 @@ 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: 0; + right: 100%; + display: none; + padding-right: 4px; +} + +.block:hover > .block-handle, +.block-selected > .block-handle, +.block-handle:focus-within { + display: flex; +} + +.block-delete { + width: 24px; + height: 24px; + padding: 0; + font: inherit; + line-height: 1; + color: #666; + background: transparent; + border: 1px solid rgba(127, 127, 127, 0.4); + border-radius: 4px; + cursor: pointer; +} + +.block-delete:hover { + color: #b91c1c; + border-color: #b91c1c; +} + .properties-panel .field { display: flex; flex-direction: column; diff --git a/src/components/Worksheet.tsx b/src/components/Worksheet.tsx index f22ac0b..e0bd3e4 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,19 @@ 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 } From c7e8ef79177455372732f43484f48ca6718c9234 Mon Sep 17 00:00:00 2001 From: Marian Hahne Date: Tue, 20 Oct 2026 12:52:11 +0200 Subject: [PATCH 2/3] feat(app): use a centered trash icon for the Block delete button (#5) Co-Authored-By: Claude Sonnet 5.5 --- src/App.css | 20 +++++++++++--------- src/components/Worksheet.tsx | 14 +++++++++++++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/App.css b/src/App.css index 49df1e6..a13e26f 100644 --- a/src/App.css +++ b/src/App.css @@ -186,21 +186,23 @@ body, } .block-delete { - width: 24px; - height: 24px; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; padding: 0; - font: inherit; - line-height: 1; - color: #666; + color: #777; background: transparent; - border: 1px solid rgba(127, 127, 127, 0.4); - border-radius: 4px; + border: none; + border-radius: 6px; cursor: pointer; } -.block-delete:hover { +.block-delete:hover, +.block-delete:focus-visible { color: #b91c1c; - border-color: #b91c1c; + background: rgba(185, 28, 28, 0.1); } .properties-panel .field { diff --git a/src/components/Worksheet.tsx b/src/components/Worksheet.tsx index e0bd3e4..2807647 100644 --- a/src/components/Worksheet.tsx +++ b/src/components/Worksheet.tsx @@ -104,7 +104,19 @@ function BlockHost({ dispatch({ type: 'remove', id: block.id }); }} > - × +
Date: Tue, 20 Oct 2026 12:55:23 +0200 Subject: [PATCH 3/3] feat(app): center the Block handle vertically next to its Block (#5) Co-Authored-By: Claude Sonnet 5.5 --- src/App.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/App.css b/src/App.css index a13e26f..5d716dc 100644 --- a/src/App.css +++ b/src/App.css @@ -173,8 +173,9 @@ body, /* Sits in the Page's left margin; wide enough to bridge the gap so hover persists. */ .block-handle { position: absolute; - top: 0; + top: 50%; right: 100%; + transform: translateY(-50%); display: none; padding-right: 4px; }