feat(workspace): merge bullet on Backspace, render hashtags inline
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m8s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Sonnet 5.5 committed 2026-10-28 08:31:25 +01:00
1 parent bc13235da6
commit c7e05b385c
3 files changed
+104 -29

No files matched your search

@@ -14,7 +14,6 @@ import {
ListTodo, ListTodo,
Minus, Minus,
MessageSquare, MessageSquare,
Hash,
ExternalLink, ExternalLink,
Maximize2, Maximize2,
Link as LinkIcon, Link as LinkIcon,
@@ -57,6 +56,7 @@ export interface OutlinerBlockProps {
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
onMergeWithPrevious?: (blockId: string, content: string) => boolean;
onIndentBlock: (blockId: string, delta: 1 | -1) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onFocusBlock?: (blockId: string) => void; onFocusBlock?: (blockId: string) => void;
@@ -86,6 +86,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onUpdateBlock, onUpdateBlock,
onCreateBlockBelow, onCreateBlockBelow,
onDeleteBlock, onDeleteBlock,
onMergeWithPrevious,
onIndentBlock, onIndentBlock,
onNavigateFocus, onNavigateFocus,
onFocusBlock, onFocusBlock,
@@ -124,6 +125,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onPasteMultiLinesRef.current = onPasteMultiLines; onPasteMultiLinesRef.current = onPasteMultiLines;
const onDeleteBlockRef = useRef(onDeleteBlock); const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock; onDeleteBlockRef.current = onDeleteBlock;
const onMergeWithPreviousRef = useRef(onMergeWithPrevious);
onMergeWithPreviousRef.current = onMergeWithPrevious;
const onIndentBlockRef = useRef(onIndentBlock); const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock; onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus); const onNavigateFocusRef = useRef(onNavigateFocus);
@@ -455,6 +458,28 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return; 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 // 4. Arrow navigation between blocks
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
const el = textareaRef.current; const el = textareaRef.current;
@@ -489,9 +514,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return parts.filter(Boolean); return parts.filter(Boolean);
}, [localContent]); }, [localContent]);
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
const hasSpecialTokens = tokens.some( const hasSpecialTokens = tokens.some(
(t) => (t) =>
t.startsWith('#') ||
(t.startsWith('[[') && t.endsWith(']]')) || (t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t) /^https?:\/\//.test(t)
@@ -503,7 +528,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isDragTarget isDragTarget
? 'border-t-2 border-accent bg-accent/10' ? 'border-t-2 border-accent bg-accent/10'
: isTargeted : isTargeted
? 'bg-accent/15 ring-2 ring-accent' ? 'bg-panel-elevated'
: activeFocus : activeFocus
? 'bg-panel-elevated' ? 'bg-panel-elevated'
: 'hover:bg-panel' : 'hover:bg-panel'
@@ -680,7 +705,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
> >
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span className={activeFocus ? 'invisible' : ''}> <span className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent) : '\u200b'} {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span> </span>
{/* Inline Badge and 3-Dots directly behind the text */} {/* Inline Badge and 3-Dots directly behind the text */}
@@ -809,20 +834,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{hasSpecialTokens && !isFocused && ( {hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5"> <div className="flex flex-wrap items-center gap-1 mt-0.5">
{tokens.map((part, idx) => { {tokens.map((part, idx) => {
if (part.startsWith('#') && part.length > 1) {
const tag = part.slice(1);
return (
<button
key={idx}
type="button"
onClick={() => onTagClick?.(tag)}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
>
<Hash className="w-2.5 h-2.5 opacity-80" />
<span>{tag}</span>
</button>
);
}
if (part.startsWith('[[') && part.endsWith(']]')) { if (part.startsWith('[[') && part.endsWith(']]')) {
const page = part.slice(2, -2).trim(); const page = part.slice(2, -2).trim();
return ( return (
@@ -50,27 +50,34 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map()); const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(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( const focusBlockInput = useCallback(
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => { (blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => {
setFocusedBlockId(blockId); setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId); const el = textareaMapRef.current.get(blockId);
if (el) { if (el) {
requestAnimationFrame(() => { const apply = () => {
el.focus(); el.focus();
try { try {
if (cursor === 'select') { if (cursor === 'select') {
el.select(); el.select();
} else { } 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); el.setSelectionRange(pos, pos);
} }
} catch { } catch {
// ignore // ignore
} }
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); 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 { } else {
pendingFocusRef.current = { blockId, cursor }; pendingFocusRef.current = { blockId, cursor };
} }
@@ -514,6 +521,30 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock] [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( const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => { (blockId: string, delta: 1 | -1) => {
if (!canEdit) return; if (!canEdit) return;
@@ -672,6 +703,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onCreateBlockBelow={handleCreateBlockBelow} onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines} onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock} onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock} onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus} onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId} onFocusBlock={setFocusedBlockId}
+38 -6
View File
@@ -7,11 +7,20 @@ import React from 'react';
* - ~~strikethrough~~ * - ~~strikethrough~~
* - `code` * - `code`
* - [label](url) * - [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 []; if (!text) return [];
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g; const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
const regex = new RegExp(
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
'g'
);
const elements: React.ReactNode[] = []; const elements: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
@@ -24,7 +33,30 @@ function renderInlineTokens(text: string): React.ReactNode[] {
const token = match[0]; const token = match[0];
const key = `${match.index}-${token}`; 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(
<span
key={key}
role="button"
tabIndex={-1}
onClick={(e) => {
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}
</span>
);
}
} else if (token.startsWith('`') && token.endsWith('`')) {
elements.push( elements.push(
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50"> <code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
{token.slice(1, -1)} {token.slice(1, -1)}
@@ -92,7 +124,7 @@ function renderInlineTokens(text: string): React.ReactNode[] {
* - `code` * - `code`
* - [label](url) * - [label](url)
*/ */
export function renderInlineMarkdown(text: string): React.ReactNode[] { export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
if (!text) return []; if (!text) return [];
if (text.includes('```')) { if (text.includes('```')) {
@@ -122,14 +154,14 @@ export function renderInlineMarkdown(text: string): React.ReactNode[] {
</div> </div>
); );
} else if (part) { } else if (part) {
nodes.push(...renderInlineTokens(part)); nodes.push(...renderInlineTokens(part, options));
} }
}); });
return nodes; return nodes;
} }
return renderInlineTokens(text); return renderInlineTokens(text, options);
} }
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) { export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {