From c7e05b385c1c77501bf25ff2bd4e1f8c00dcb261 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 08:31:25 +0100 Subject: [PATCH] feat(workspace): merge bullet on Backspace, render hashtags inline - Backspace at the start of a non-empty bullet appends its text to the previous visible bullet (skipped for blocks with children and for code/divider/table/progress blocks). - Hashtags are rendered inline as clickable chips instead of a badge row below the text, so lines no longer grow taller. - Targeted/highlighted bullets use a neutral background instead of accent. Co-Authored-By: Claude Sonnet 5.5 --- .../src/features/workspace/OutlinerBlock.tsx | 47 ++++++++++++------- .../src/features/workspace/OutlinerTree.tsx | 42 +++++++++++++++-- frontend/src/lib/markdown.tsx | 44 ++++++++++++++--- 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 9abf872..700cf3b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -14,7 +14,6 @@ import { ListTodo, Minus, MessageSquare, - Hash, ExternalLink, Maximize2, Link as LinkIcon, @@ -57,6 +56,7 @@ export interface OutlinerBlockProps { onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; + onMergeWithPrevious?: (blockId: string, content: string) => boolean; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onFocusBlock?: (blockId: string) => void; @@ -86,6 +86,7 @@ export const OutlinerBlock: React.FC = ({ onUpdateBlock, onCreateBlockBelow, onDeleteBlock, + onMergeWithPrevious, onIndentBlock, onNavigateFocus, onFocusBlock, @@ -124,6 +125,8 @@ export const OutlinerBlock: React.FC = ({ onPasteMultiLinesRef.current = onPasteMultiLines; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; + const onMergeWithPreviousRef = useRef(onMergeWithPrevious); + onMergeWithPreviousRef.current = onMergeWithPrevious; const onIndentBlockRef = useRef(onIndentBlock); onIndentBlockRef.current = onIndentBlock; const onNavigateFocusRef = useRef(onNavigateFocus); @@ -455,6 +458,28 @@ export const OutlinerBlock: React.FC = ({ return; } + // 3b. Backspace at the very start of a non-empty block -> append its text to the previous block + if ( + e.key === 'Backspace' && + localContent !== '' && + block.type !== 'code' && + onMergeWithPreviousRef.current && + e.currentTarget.selectionStart === 0 && + e.currentTarget.selectionEnd === 0 + ) { + if (onMergeWithPreviousRef.current(block.id, localContent)) { + // Text lives in the previous block afterwards, so drop this block's pending save + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + e.preventDefault(); + e.stopPropagation(); + setSlashSearch(null); + } + return; + } + // 4. Arrow navigation between blocks if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { const el = textareaRef.current; @@ -489,9 +514,9 @@ export const OutlinerBlock: React.FC = ({ return parts.filter(Boolean); }, [localContent]); + // #Hashtags are rendered inline inside the text, so only links need the badge bar below const hasSpecialTokens = tokens.some( (t) => - t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]')) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || /^https?:\/\//.test(t) @@ -503,7 +528,7 @@ export const OutlinerBlock: React.FC = ({ isDragTarget ? 'border-t-2 border-accent bg-accent/10' : isTargeted - ? 'bg-accent/15 ring-2 ring-accent' + ? 'bg-panel-elevated' : activeFocus ? 'bg-panel-elevated' : 'hover:bg-panel' @@ -680,7 +705,7 @@ export const OutlinerBlock: React.FC = ({ > {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} - {localContent ? renderInlineMarkdown(localContent) : '\u200b'} + {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'} {/* Inline Badge and 3-Dots directly behind the text */} @@ -809,20 +834,6 @@ export const OutlinerBlock: React.FC = ({ {hasSpecialTokens && !isFocused && (
{tokens.map((part, idx) => { - if (part.startsWith('#') && part.length > 1) { - const tag = part.slice(1); - return ( - - ); - } if (part.startsWith('[[') && part.endsWith(']]')) { const page = part.slice(2, -2).trim(); return ( diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 6c6aca5..e7c8910 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -50,27 +50,34 @@ export const OutlinerTree = forwardRef(({ const [dragOverIndex, setDragOverIndex] = useState(null); const textareaMapRef = useRef>(new Map()); - const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null); + const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null); const focusBlockInput = useCallback( - (blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => { + (blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => { setFocusedBlockId(blockId); const el = textareaMapRef.current.get(blockId); if (el) { - requestAnimationFrame(() => { + const apply = () => { el.focus(); try { if (cursor === 'select') { el.select(); } else { - const pos = cursor === 'end' ? el.value.length : 0; + const pos = + typeof cursor === 'number' + ? Math.min(cursor, el.value.length) + : cursor === 'end' + ? el.value.length + : 0; el.setSelectionRange(pos, pos); } } catch { // ignore } el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); + }; + // A numeric position refers to content that was just changed, so wait one extra frame for it to render + requestAnimationFrame(() => (typeof cursor === 'number' ? requestAnimationFrame(apply) : apply())); } else { pendingFocusRef.current = { blockId, cursor }; } @@ -514,6 +521,30 @@ export const OutlinerTree = forwardRef(({ [blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock] ); + // Backspace at the start of a non-empty block: append its text to the previous visible block + const handleMergeWithPrevious = useCallback( + (blockId: string, content: string) => { + if (!canEdit) return false; + const mergeable = ['text', 'paragraph', 'bullet', 'todo', 'h1', 'h2', 'h3', 'callout']; + const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId); + if (visIdx <= 0) return false; + const prev = visibleEntries[visIdx - 1].block; + const idx = blocks.findIndex((b) => b.id === blockId); + const cur = blocks[idx]; + if (!cur || !mergeable.includes(prev.type) || !mergeable.includes(cur.type)) return false; + // Blocks with children would leave them orphaned at a wrong indent level + const next = blocks[idx + 1]; + if (next && (next.level || 0) > (cur.level || 0)) return false; + + const joinPos = (prev.content || '').length; + onUpdateBlock({ ...prev, content: (prev.content || '') + content }, true); + onDeleteBlock(cur.id); + focusBlockInput(prev.id, joinPos); + return true; + }, + [blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock, visibleEntries] + ); + const handleIndentBlock = useCallback( (blockId: string, delta: 1 | -1) => { if (!canEdit) return; @@ -672,6 +703,7 @@ export const OutlinerTree = forwardRef(({ onCreateBlockBelow={handleCreateBlockBelow} onPasteMultiLines={handlePasteMultiLines} onDeleteBlock={handleDeleteBlock} + onMergeWithPrevious={handleMergeWithPrevious} onIndentBlock={handleIndentBlock} onNavigateFocus={handleNavigateFocus} onFocusBlock={setFocusedBlockId} diff --git a/frontend/src/lib/markdown.tsx b/frontend/src/lib/markdown.tsx index 6044a50..9f7471f 100644 --- a/frontend/src/lib/markdown.tsx +++ b/frontend/src/lib/markdown.tsx @@ -7,11 +7,20 @@ import React from 'react'; * - ~~strikethrough~~ * - `code` * - [label](url) + * - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip */ -function renderInlineTokens(text: string): React.ReactNode[] { +export interface InlineMarkdownOptions { + onTagClick?: (tag: string) => void; +} + +function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] { if (!text) return []; - const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g; + const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : ''; + const regex = new RegExp( + '(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')', + 'g' + ); const elements: React.ReactNode[] = []; let lastIndex = 0; @@ -24,7 +33,30 @@ function renderInlineTokens(text: string): React.ReactNode[] { const token = match[0]; const key = `${match.index}-${token}`; - if (token.startsWith('`') && token.endsWith('`')) { + if (token.startsWith('#')) { + const prevChar = match.index > 0 ? text[match.index - 1] : ''; + if (prevChar && !/[\s(]/.test(prevChar)) { + // e.g. an URL fragment like https://x/#anchor: keep as plain text + elements.push(token); + } else { + const tag = token.slice(1); + elements.push( + { + e.stopPropagation(); + options?.onTagClick?.(tag); + }} + className="text-accent bg-accent-dim hover:bg-accent/20 rounded-btn px-0.5 cursor-pointer transition-colors" + title={`Nach #${tag} filtern`} + > + {token} + + ); + } + } else if (token.startsWith('`') && token.endsWith('`')) { elements.push( {token.slice(1, -1)} @@ -92,7 +124,7 @@ function renderInlineTokens(text: string): React.ReactNode[] { * - `code` * - [label](url) */ -export function renderInlineMarkdown(text: string): React.ReactNode[] { +export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] { if (!text) return []; if (text.includes('```')) { @@ -122,14 +154,14 @@ export function renderInlineMarkdown(text: string): React.ReactNode[] {
); } else if (part) { - nodes.push(...renderInlineTokens(part)); + nodes.push(...renderInlineTokens(part, options)); } }); return nodes; } - return renderInlineTokens(text); + return renderInlineTokens(text, options); } export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {