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

@@ -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}