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,
|
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}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
Reference in new issue
Block a user