feat(workspace): polish outliner slash commands, typography and actions overlay
This commit is contained in:
1 parent
ad4829796c
commit
fd72500d17
3 files changed
+559
-196
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
Mic,
|
||||
AudioLines,
|
||||
FileOutput,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
@@ -55,6 +56,7 @@ export interface SlashCommand {
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: <Type className="w-3.5 h-3.5" />, aliases: ['p', 'paragraph', 'bullet', 'standard'] },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||
@@ -156,7 +158,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const activeFocus = isFocused || isLocallyFocused;
|
||||
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
|
||||
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null);
|
||||
const slashSearch = slashMatchInfo ? slashMatchInfo.query : null;
|
||||
const linkBeforeRef = useRef<string>('');
|
||||
const linkAfterRef = useRef<string>('');
|
||||
const dictationBeforeRef = useRef<string>('');
|
||||
const dictationAfterRef = useRef<string>('');
|
||||
const slashMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const todoMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const blurSlashTimerRef = useRef<number | null>(null);
|
||||
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
|
||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
||||
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
||||
@@ -272,9 +282,48 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if (blurSlashTimerRef.current !== null) {
|
||||
window.clearTimeout(blurSlashTimerRef.current);
|
||||
blurSlashTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close slash command menu on click outside
|
||||
useEffect(() => {
|
||||
if (!slashMatchInfo) return;
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (
|
||||
slashMenuRef.current &&
|
||||
!slashMenuRef.current.contains(target) &&
|
||||
textareaRef.current &&
|
||||
!textareaRef.current.contains(target)
|
||||
) {
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [slashMatchInfo]);
|
||||
|
||||
// Close todo menu on click outside
|
||||
useEffect(() => {
|
||||
if (!showTodoMenu) return;
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (todoMenuRef.current && !todoMenuRef.current.contains(target)) {
|
||||
setShowTodoMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [showTodoMenu]);
|
||||
|
||||
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
||||
@@ -358,22 +407,54 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}, [adjustHeight, block.type]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (slashSearch === null) return [];
|
||||
const q = slashSearch.toLowerCase();
|
||||
if (!slashMatchInfo) return [];
|
||||
const q = slashMatchInfo.query.toLowerCase();
|
||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
||||
// Media only in an otherwise empty bullet
|
||||
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
|
||||
return SLASH_COMMANDS.filter(
|
||||
(c) =>
|
||||
(!c.media || emptyBullet) &&
|
||||
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
|
||||
// The bullet's text becomes the title of the new note
|
||||
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
|
||||
(c.label.toLowerCase().includes(cmdQuery) ||
|
||||
c.desc.toLowerCase().includes(cmdQuery) ||
|
||||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
|
||||
);
|
||||
}, [slashSearch, localContent, onExtractBlock]);
|
||||
|
||||
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
|
||||
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
|
||||
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
|
||||
const isRestEmpty = restOfBlock === '';
|
||||
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
|
||||
|
||||
return SLASH_COMMANDS.filter((c) => {
|
||||
const matchesQuery =
|
||||
c.label.toLowerCase().includes(cmdQuery) ||
|
||||
c.desc.toLowerCase().includes(cmdQuery) ||
|
||||
(c.aliases || []).some((a) => a.includes(cmdQuery));
|
||||
if (!matchesQuery) return false;
|
||||
|
||||
// /link and /dictation everywhere
|
||||
if (c.type === 'link') return true;
|
||||
if (c.type === 'dictation') {
|
||||
return ['server', 'browser'].includes(currentVoiceConfig().mode);
|
||||
}
|
||||
|
||||
// /divider, /image, /audio, /code only when rest of block is empty
|
||||
if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') {
|
||||
return isRestEmpty;
|
||||
}
|
||||
|
||||
// /extract only when note has content and is at start/end
|
||||
if (c.type === 'extract') {
|
||||
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
|
||||
}
|
||||
|
||||
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
|
||||
if (
|
||||
c.type === 'h1' ||
|
||||
c.type === 'h2' ||
|
||||
c.type === 'h3' ||
|
||||
c.type === 'todo' ||
|
||||
c.type === 'callout' ||
|
||||
c.type === 'bullet'
|
||||
) {
|
||||
return isAtStartOrEnd;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [slashMatchInfo, localContent, onExtractBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slashSearch === null) return;
|
||||
@@ -381,15 +462,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}, [slashIndex, slashSearch]);
|
||||
|
||||
const applySlashCommand = (cmd: SlashCommand) => {
|
||||
setSlashSearch(null);
|
||||
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
|
||||
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
|
||||
if (blurSlashTimerRef.current !== null) {
|
||||
window.clearTimeout(blurSlashTimerRef.current);
|
||||
blurSlashTimerRef.current = null;
|
||||
}
|
||||
const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0;
|
||||
const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length;
|
||||
setSlashMatchInfo(null);
|
||||
|
||||
const before = localContent.slice(0, slashStart);
|
||||
const after = localContent.slice(slashEnd);
|
||||
const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
if (cmd.type === 'link') {
|
||||
linkBeforeRef.current = before;
|
||||
linkAfterRef.current = after;
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type === 'dictation') {
|
||||
dictationBeforeRef.current = before;
|
||||
dictationAfterRef.current = after;
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
flushDebounce(clean);
|
||||
setMediaPicker('dictation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type === 'extract') {
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
@@ -397,31 +500,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onExtractBlock?.(block.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.media) {
|
||||
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
flushDebounce('');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio');
|
||||
return;
|
||||
}
|
||||
|
||||
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
|
||||
const langOverride =
|
||||
cmd.type === 'code' && codeLangMatch
|
||||
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
|
||||
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||
: undefined;
|
||||
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(clean, {
|
||||
type: cmd.type as BlockType, // media commands returned above
|
||||
type: cmd.type as BlockType,
|
||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsertLink = (linkMarkdown: string) => {
|
||||
setShowLinkModal(false);
|
||||
if (localContent.trim() === '') {
|
||||
const b = linkBeforeRef.current ?? '';
|
||||
const a = linkAfterRef.current ?? '';
|
||||
if (b.trim() === '' && a.trim() === '') {
|
||||
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
||||
if (webMatch) {
|
||||
const bareUrl = webMatch[1];
|
||||
@@ -431,10 +541,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
||||
const before = localContent.slice(0, cursor);
|
||||
const after = localContent.slice(cursor);
|
||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
||||
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
|
||||
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
|
||||
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
|
||||
setLocalContent(newContent);
|
||||
localContentRef.current = newContent;
|
||||
flushDebounce(newContent);
|
||||
@@ -516,18 +625,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||
val = val.slice(4);
|
||||
typeOverride = 'h3';
|
||||
} else if (
|
||||
(val.startsWith('/text ') ||
|
||||
val.startsWith('/p ') ||
|
||||
val.startsWith('/paragraph ') ||
|
||||
val.startsWith('/bullet ') ||
|
||||
val.startsWith('/standard ')) &&
|
||||
block.type !== 'bullet'
|
||||
) {
|
||||
val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, '');
|
||||
typeOverride = 'bullet';
|
||||
}
|
||||
|
||||
// Check for slash command invocation
|
||||
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||
// Check for slash command invocation at cursor position
|
||||
const sel = e.currentTarget.selectionStart;
|
||||
const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length;
|
||||
const textBefore = val.slice(0, cursor);
|
||||
const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||
if (slashMatch) {
|
||||
setSlashSearch(slashMatch[2]);
|
||||
const slashStart = slashMatch.index! + slashMatch[1].length;
|
||||
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
|
||||
setSlashIndex(0);
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
@@ -543,6 +666,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
type: typeOverride,
|
||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -557,6 +681,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (blurSlashTimerRef.current !== null) {
|
||||
window.clearTimeout(blurSlashTimerRef.current);
|
||||
blurSlashTimerRef.current = null;
|
||||
}
|
||||
isFocusedRef.current = true;
|
||||
setIsLocallyFocused(true);
|
||||
onFocusBlock?.(block.id);
|
||||
@@ -567,6 +695,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isFocusedRef.current = false;
|
||||
setIsLocallyFocused(false);
|
||||
onBlurBlock?.(block.id);
|
||||
if (blurSlashTimerRef.current !== null) {
|
||||
window.clearTimeout(blurSlashTimerRef.current);
|
||||
}
|
||||
blurSlashTimerRef.current = window.setTimeout(() => {
|
||||
setSlashMatchInfo(null);
|
||||
}, 150);
|
||||
if (isPastingRef.current) {
|
||||
isPastingRef.current = false;
|
||||
return;
|
||||
@@ -749,7 +883,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (slashSearch !== null) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
return;
|
||||
}
|
||||
if (filteredSlashCommands.length > 0) {
|
||||
@@ -789,7 +923,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
|
||||
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
||||
if (fenceMatch) {
|
||||
@@ -844,7 +978,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (e.key === 'Backspace' && localContent === '') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
@@ -870,7 +1004,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -920,6 +1054,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
||||
block.type === 'h1'
|
||||
? 'mt-2.5 sm:mt-3'
|
||||
: block.type === 'h2'
|
||||
? 'mt-1.5 sm:mt-2'
|
||||
: ''
|
||||
} ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
@@ -1047,14 +1187,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<DictationBlock
|
||||
onDone={(text) => {
|
||||
setMediaPicker(null);
|
||||
setLocalContent(text);
|
||||
localContentRef.current = text;
|
||||
flushDebounce(text);
|
||||
const b = dictationBeforeRef.current ?? '';
|
||||
const a = dictationAfterRef.current ?? '';
|
||||
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
|
||||
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
|
||||
const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim();
|
||||
setLocalContent(newContent);
|
||||
localContentRef.current = newContent;
|
||||
flushDebounce(newContent);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
const pos = (b + spaceBefore + text).length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -1104,8 +1250,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
<div
|
||||
className="relative py-2 group/divider cursor-pointer w-full"
|
||||
onClick={() => {
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<hr
|
||||
className={`border-t transition-colors ${
|
||||
activeFocus ? 'border-accent ring-1 ring-accent/30' : 'border-border'
|
||||
}`}
|
||||
/>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value=""
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={() => {}}
|
||||
onKeyDown={(e) => {
|
||||
if (!canEdit) return;
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce('', { type: 'bullet' });
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
return;
|
||||
}
|
||||
handleKeyDown(e);
|
||||
}}
|
||||
className="sr-only"
|
||||
aria-label="Trennlinie"
|
||||
/>
|
||||
</div>
|
||||
) : block.type === 'code' ? (
|
||||
<CodeBlockEditor
|
||||
@@ -1145,7 +1326,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full min-h-[1.5rem]">
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
{/* View Layer with Inline Markdown */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (!activeFocus && canFocus && textareaRef.current) {
|
||||
@@ -1158,11 +1339,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
? 'text-lg sm:text-xl font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
@@ -1172,104 +1353,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||
</span>
|
||||
|
||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
||||
{block.type === 'todo' && (
|
||||
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
|
||||
{block.taskId && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTask?.(block.taskId as string);
|
||||
}}
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
||||
title="Erweiterte Aufgabe öffnen"
|
||||
>
|
||||
<CheckSquare className="w-2.5 h-2.5" />
|
||||
<span>Erweitert</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<span className="relative inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTodoMenu((prev) => !prev);
|
||||
}}
|
||||
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="Aufgaben-Optionen"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{showTodoMenu && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!block.taskId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
const targetDocId = docId || block.documentId || block.docId || '';
|
||||
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
||||
content: localContent || 'Aufgabe',
|
||||
checked: Boolean(block.checked),
|
||||
});
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: thought.id,
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
onOpenTask?.(thought.id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>In erweiterte Aufgabe umwandeln</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
if (block.taskId) {
|
||||
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
||||
}
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: '',
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Verknüpfung aufheben</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
@@ -1285,15 +1368,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onClick={() => {
|
||||
if (slashMatchInfo) {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
const cursor = ta.selectionStart;
|
||||
const textBefore = ta.value.slice(0, cursor);
|
||||
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
|
||||
if (match) {
|
||||
const slashStart = match.index! + match[1].length;
|
||||
setSlashMatchInfo({ query: match[2], slashStart, cursor });
|
||||
} else {
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) && slashMatchInfo) {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
const cursor = ta.selectionStart;
|
||||
const textBefore = ta.value.slice(0, cursor);
|
||||
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
|
||||
if (match) {
|
||||
const slashStart = match.index! + match[1].length;
|
||||
setSlashMatchInfo({ query: match[2], slashStart, cursor });
|
||||
} else {
|
||||
setSlashMatchInfo(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
? 'text-lg sm:text-xl font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
@@ -1302,6 +1417,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
|
||||
{block.type === 'todo' && block.taskId && (
|
||||
<div className="flex items-center gap-1 mt-0.5 select-none">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTask?.(block.taskId as string);
|
||||
}}
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
||||
title="Erweiterte Aufgabe öffnen"
|
||||
>
|
||||
<CheckSquare className="w-2.5 h-2.5" />
|
||||
<span>Erweitert</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflict copy: text that lost against someone else's change */}
|
||||
{block.conflictOf && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
||||
@@ -1393,7 +1527,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Floating Slash Commands Menu */}
|
||||
{slashSearch !== null && canEdit && (
|
||||
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||
Befehl auswählen
|
||||
</div>
|
||||
@@ -1411,7 +1548,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||
i === slashIndex
|
||||
? 'bg-accent text-on-accent'
|
||||
? 'bg-accent-dim text-accent font-semibold'
|
||||
: 'text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
@@ -1432,29 +1569,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branch into its own note (desktop; phones use /extract) */}
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExtractBlock(block.id)}
|
||||
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
|
||||
>
|
||||
<FileOutput className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Zoom / Focus button on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
// Phones zoom by tapping the bullet; the button would only take width from the text there
|
||||
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Someone else is editing this block */}
|
||||
{lockedBy && (
|
||||
<span
|
||||
@@ -1466,29 +1580,164 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||
onDeleteBlockRef.current(block.id, true);
|
||||
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
|
||||
<div
|
||||
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
|
||||
activeFocus
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||
}`}
|
||||
>
|
||||
{/* Convert back to bullet for headings, callouts, divider */}
|
||||
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(localContent, { type: 'bullet', checked: false });
|
||||
}}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="In normalen Text umwandeln"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* To-Do 3-dots Menu */}
|
||||
{block.type === 'todo' && canEdit && (
|
||||
<div className="relative inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTodoMenu((prev) => !prev);
|
||||
}}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="Aufgaben-Optionen"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{showTodoMenu && (
|
||||
<div
|
||||
ref={todoMenuRef}
|
||||
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!block.taskId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
const targetDocId = docId || block.documentId || block.docId || '';
|
||||
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
||||
content: localContent || 'Aufgabe',
|
||||
checked: Boolean(block.checked),
|
||||
});
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: thought.id,
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
onOpenTask?.(thought.id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>In erweiterte Aufgabe umwandeln</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
if (block.taskId) {
|
||||
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
||||
}
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: '',
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Verknüpfung aufheben</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowTodoMenu(false);
|
||||
refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(localContent, { type: 'bullet', checked: false, taskId: '' });
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer border-t border-border mt-0.5 pt-1.5"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5 text-accent" />
|
||||
<span>In normalen Text umwandeln</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch into its own note */}
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExtractBlock(block.id)}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
|
||||
>
|
||||
<FileOutput className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Zoom / Focus button */}
|
||||
{onZoomBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock(block.id)}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||
onDeleteBlockRef.current(block.id, true);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlockRef.current(block.id, false);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlockRef.current(block.id, false);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
activeFocus
|
||||
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
|
||||
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
|
||||
}
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
}}
|
||||
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-status-err rounded-btn transition-colors cursor-pointer"
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Insertion Modal */}
|
||||
{showLinkModal && (
|
||||
|
||||
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
|
||||
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||
|
||||
fireEvent.change(ta, { target: { value: 'Text /co' } });
|
||||
fireEvent.change(ta, { target: { value: 'Text /ca' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/code')).toBeTruthy();
|
||||
expect(queryByText('/callout')).toBeTruthy();
|
||||
|
||||
// Removing the slash closes the menu
|
||||
fireEvent.change(ta, { target: { value: 'Text ' } });
|
||||
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
|
||||
expect(readerTa.value).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outliner polish (Teil A)', () => {
|
||||
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
|
||||
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
|
||||
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
|
||||
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
|
||||
|
||||
// Menu should be open with /link, but without /divider or /h1
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/link')).toBeTruthy();
|
||||
expect(queryByText('/divider')).toBeNull();
|
||||
expect(queryByText('/h1')).toBeNull();
|
||||
|
||||
// Select /link by pressing Enter
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
|
||||
// Link modal opens
|
||||
expect(queryByText('Web-Link')).toBeTruthy();
|
||||
fireEvent.click(getByText('Web-Link'));
|
||||
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
|
||||
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
|
||||
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
|
||||
|
||||
// Result has link inserted in the middle
|
||||
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
|
||||
});
|
||||
|
||||
it('does not offer /divider at end of text, but offers it in empty block', () => {
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
|
||||
const { container, queryByText } = renderBlock(block);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
// At end of non-empty text
|
||||
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
|
||||
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/divider')).toBeNull();
|
||||
|
||||
// In an empty block
|
||||
fireEvent.change(ta, { target: { value: '/div' } });
|
||||
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/divider')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('converts H1 block back to bullet via /text slash command', () => {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
|
||||
const { container, queryByText } = renderBlock(block, {
|
||||
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
|
||||
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/text')).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
|
||||
const last = updates[updates.length - 1];
|
||||
expect(last?.type).toBe('bullet');
|
||||
expect(last?.content).toBe('Meine Überschrift');
|
||||
});
|
||||
|
||||
it('closes slash menu on mousedown outside', () => {
|
||||
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||
const { container, queryByText } = renderBlock(block);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
fireEvent.change(ta, { target: { value: '/' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
|
||||
// Click outside on document body
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||
});
|
||||
|
||||
it('converts focused divider to empty bullet on Backspace', () => {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
|
||||
const { container } = renderBlock(block, {
|
||||
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||
const last = updates[updates.length - 1];
|
||||
expect(last?.type).toBe('bullet');
|
||||
expect(last?.content).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user