From 05355989a8ef3e4510a1a9b7c36f64df8573d53a Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 22:09:47 +0100 Subject: [PATCH] fix(workspace): Enter keeps an empty bullet on its level when siblings follow Outdenting an empty bullet on Enter only happens when no bullet on the same level follows in the branch; otherwise a new bullet is added below. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../src/features/workspace/OutlinerBlock.tsx | 9 ++++--- .../src/features/workspace/OutlinerTree.tsx | 16 +++++++++++ .../__tests__/OutlinerBlock.caret.test.tsx | 27 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b9984..4dd1f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). ### Changed +- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet). - Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber. - Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein. - Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt. diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index d17d65b..5db0903 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -106,6 +106,8 @@ export interface OutlinerBlockProps { onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; /** Number or letter of a numbered-list item ("1.", "b.", "iii.") */ listMarker?: string; + /** Another bullet on the same level follows: Enter in this empty bullet adds a bullet instead of outdenting */ + siblingFollows?: boolean; lockedBy?: string; // someone else is editing this block canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below onResolveConflict?: (blockId: string, accept: boolean) => void; @@ -155,6 +157,7 @@ export const OutlinerBlock: React.FC = ({ registerTextareaRef, onPasteMultiLines, listMarker, + siblingFollows = false, lockedBy, canAppendBelow, onResolveConflict, @@ -961,13 +964,13 @@ export const OutlinerBlock: React.FC = ({ const minLevel = levelOffset || 0; if (localContent === '') { - if (Math.max(0, block.level || 0) > minLevel) { + if (Math.max(0, block.level || 0) > minLevel && !siblingFollows) { flushDebounce(localContent); onIndentBlockRef.current(block.id, -1); return; } - // Enter in an empty numbered item at the top ends the list - if (block.type === 'numbered') { + // Enter in an empty last numbered item at the top ends the list + if (block.type === 'numbered' && !siblingFollows) { flushDebounce('', { type: 'text' }); return; } diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index bb4957c..100ccd1 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -244,6 +244,21 @@ export const OutlinerTree = forwardRef(({ const listLabels = useMemo(() => numberedLabels(blocks), [blocks]); + // Whether another bullet on the same level follows (before the branch ends); an empty bullet + // is then not outdented by Enter + const siblingFollows = useCallback( + (index: number) => { + const level = blocks[index]?.level || 0; + for (let i = index + 1; i < blocks.length; i++) { + const l = blocks[i].level || 0; + if (l < level) return false; + if (l === level) return true; + } + return false; + }, + [blocks] + ); + const handleCreateBlockBelow = useCallback( (afterBlockId: string, newContent: string = '') => { if (!canEdit) return; @@ -617,6 +632,7 @@ export const OutlinerTree = forwardRef(({ block={block} index={index} isLast={isLast} + siblingFollows={siblingFollows(index)} maxAllowedLevel={maxAllowedLevel} hasChildren={hasChildren} isFocused={focusedBlockId === block.id} diff --git a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx index dc4679e..ae0d46c 100644 --- a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx +++ b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx @@ -207,6 +207,33 @@ describe('slash command menu and outliner fixes', () => { expect(onCreateBelow).toHaveBeenCalledWith('b1', ''); }); + it('keeps an empty indented bullet on its level when another bullet on that level follows', () => { + const onIndent = vi.fn(); + const onCreateBelow = vi.fn(); + const { container } = render( + {}} + onCreateBlockBelow={onCreateBelow} + onDeleteBlock={() => {}} + onIndentBlock={onIndent} + onNavigateFocus={() => {}} + registerTextareaRef={() => {}} + /> + ); + fireEvent.keyDown(container.querySelector('textarea') as HTMLTextAreaElement, { key: 'Enter' }); + expect(onIndent).not.toHaveBeenCalled(); + expect(onCreateBelow).toHaveBeenCalledWith('b1', ''); + }); + it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => { const updates: DocumentBlock[] = []; const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;