feat(workspace): merge bullet on Backspace, render hashtags inline
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m8s
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:
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}
|
||||
|
||||
@@ -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(
|
||||
<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(
|
||||
<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)}
|
||||
@@ -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[] {
|
||||
</div>
|
||||
);
|
||||
} 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 }) {
|
||||
|
||||
Reference in new issue
Block a user