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,
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<OutlinerBlockProps> = ({
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onMergeWithPrevious,
onIndentBlock,
onNavigateFocus,
onFocusBlock,
@@ -124,6 +125,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
>
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
@@ -809,20 +834,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{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(']]')) {
const page = part.slice(2, -2).trim();
return (
@@ -50,27 +50,34 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
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(
(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<OutlinerTreeHandle, OutlinerTreeProps>(({
[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<OutlinerTreeHandle, OutlinerTreeProps>(({
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}