fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet
This commit is contained in:
1 parent
02ce554c02
commit
446f8e4649
5 files changed
+209
-48
No files matched your search
@@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock';
|
||||
import { DictationBlock } from './DictationBlock';
|
||||
import { currentVoiceConfig } from '../../lib/voice';
|
||||
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { mediaContent, parseMediaContent } from '../../lib/media';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
@@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
docId,
|
||||
block,
|
||||
index,
|
||||
isLast,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
@@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const [slashSearch, setSlashSearch] = useState<string | 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 with a plain web address (lib/linkPreview.ts)
|
||||
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
||||
const previewsOn = useLinkPreviewsEnabled();
|
||||
const previewUrl =
|
||||
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
|
||||
? firstBareUrl(localContent)
|
||||
? linkCardUrl(localContent)
|
||||
: null;
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
|
||||
@@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const q = slashSearch.toLowerCase();
|
||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
||||
// Media only in an otherwise empty bullet
|
||||
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
|
||||
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
|
||||
return SLASH_COMMANDS.filter(
|
||||
(c) =>
|
||||
(!c.media || emptyBullet) &&
|
||||
@@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
);
|
||||
}, [slashSearch, localContent, onExtractBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slashSearch === null) return;
|
||||
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
}, [slashIndex, slashSearch]);
|
||||
|
||||
const applySlashCommand = (cmd: SlashCommand) => {
|
||||
setSlashSearch(null);
|
||||
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i);
|
||||
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
|
||||
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 (cmd.type === 'link') {
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
@@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
const langOverride =
|
||||
cmd.type === 'code' && codeLangMatch
|
||||
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
|
||||
: undefined;
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
@@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
const handleInsertLink = (linkMarkdown: string) => {
|
||||
setShowLinkModal(false);
|
||||
if (localContent.trim() === '') {
|
||||
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
||||
if (webMatch) {
|
||||
const bareUrl = webMatch[1];
|
||||
setLocalContent(bareUrl);
|
||||
localContentRef.current = bareUrl;
|
||||
flushDebounce(bareUrl);
|
||||
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();
|
||||
setLocalContent(newContent);
|
||||
localContentRef.current = newContent;
|
||||
flushDebounce(newContent);
|
||||
};
|
||||
|
||||
@@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
|
||||
// Check for slash command invocation
|
||||
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||
if (slashMatch) {
|
||||
setSlashSearch(slashMatch[1]);
|
||||
setSlashSearch(slashMatch[2]);
|
||||
setSlashIndex(0);
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
@@ -600,30 +617,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
if (!canEdit) return;
|
||||
|
||||
// Slash command navigation
|
||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = filteredSlashCommands[slashIndex];
|
||||
if (selected) {
|
||||
applySlashCommand(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (slashSearch !== null) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSlashSearch(null);
|
||||
return;
|
||||
}
|
||||
if (filteredSlashCommands.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = filteredSlashCommands[slashIndex];
|
||||
if (selected) {
|
||||
applySlashCommand(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab / Shift+Tab -> Strict Indent / Outdent
|
||||
@@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
// 2. Enter (without Shift) -> If block is completely empty:
|
||||
// - If level > minLevel, outdent (Shift+Tab behaviour)
|
||||
// - If already at minLevel and is the last block, do NOT create another empty block below
|
||||
// - Otherwise split paragraph at cursor and create new block below
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
@@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onIndentBlockRef.current(block.id, -1);
|
||||
return;
|
||||
}
|
||||
if (isLast) {
|
||||
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
@@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.type}
|
||||
ref={(el) => {
|
||||
slashItemRefs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in new issue
Block a user