feat(workspace): code block with language picker and live highlighting

This commit is contained in:
sandrodach committed 2026-10-29 22:18:03 +01:00
1 parent 6a5e82431f
commit ff53f06991
13 files changed
+1089 -367

No files matched your search

@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
@@ -313,22 +314,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
(c) => c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery)
);
}, [slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean, {
type: cmd.type,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
});
};
@@ -348,37 +357,66 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
let val = e.target.value;
let typeOverride: BlockType | undefined;
let checkedOverride: boolean | undefined;
let langOverride: string | undefined;
// Quick markdown shortcuts
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
if (block.type !== 'code') {
// Quick markdown shortcuts
const fenceSpaceMatch = val.match(/^```([a-z0-9_+#.-]*)\s([\s\S]*)$/i);
const fenceDirectMatch = val.match(/^```([a-z0-9_+#.-]+)$/i);
const slashCodeSpaceMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)\s([\s\S]*)$/i);
const slashCodeDirectMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)$/i);
// Check for slash command invocation
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
if (fenceSpaceMatch) {
const rawLang = fenceSpaceMatch[1];
val = fenceSpaceMatch[2];
typeOverride = 'code';
langOverride = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
} else if (fenceDirectMatch && isUnambiguousLanguageShortcut(fenceDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(fenceDirectMatch[1]) || fenceDirectMatch[1].toLowerCase();
} else if (slashCodeSpaceMatch) {
const rawLang = slashCodeSpaceMatch[1];
val = slashCodeSpaceMatch[2];
typeOverride = 'code';
langOverride = resolveLanguageId(rawLang) || rawLang.toLowerCase();
} else if (slashCodeDirectMatch && isUnambiguousLanguageShortcut(slashCodeDirectMatch[1])) {
val = '';
typeOverride = 'code';
langOverride = resolveLanguageId(slashCodeDirectMatch[1]) || slashCodeDirectMatch[1].toLowerCase();
} else if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
// Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
} else {
setSlashSearch(null);
}
} else {
setSlashSearch(null);
}
setLocalContent(val);
localContentRef.current = val;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
@@ -387,6 +425,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (typeOverride) {
flushDebounce(val, {
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
});
return;
@@ -533,6 +572,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
e.stopPropagation();
setSlashSearch(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
const rawLang = fenceMatch[1];
const resolvedLang = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
setLocalContent('');
localContentRef.current = '';
flushDebounce('', {
type: 'code',
lang: resolvedLang,
checked: false,
});
return;
}
const minLevel = levelOffset || 0;
if (localContent === '') {
if (Math.max(0, block.level || 0) > minLevel) {
@@ -749,32 +802,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
<span>Code</span>
<button
type="button"
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
className="hover:text-accent"
>
Als Text
</button>
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
/>
</div>
<CodeBlockEditor
block={block}
content={localContent}
canEdit={canEdit}
textareaRef={textareaRef}
registerTextareaRef={registerTextareaRef}
onChangeContent={(newContent) => {
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
}}
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleContentChange={handleContentChange}
handleKeyDown={handleKeyDown}
/>
) : block.type === 'callout' ? (
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
<textarea