feat(workspace): code block with language picker and live highlighting
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user