Merge branch 'feat/code-block' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9124809f54
13 files changed
+1065
-343
No files matched your search
@@ -0,0 +1,625 @@
|
|||||||
|
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import Prism from 'prismjs';
|
||||||
|
import { Code, Copy, Check } from 'lucide-react';
|
||||||
|
import { DocumentBlock } from '../../lib/types';
|
||||||
|
|
||||||
|
Prism.manual = true;
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
(globalThis as Record<string, unknown>).Prism = Prism;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeLanguageOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeLanguageGroup {
|
||||||
|
label: string;
|
||||||
|
options: CodeLanguageOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Konfiguration & Daten',
|
||||||
|
options: [
|
||||||
|
{ id: 'yaml', label: 'YAML' },
|
||||||
|
{ id: 'json', label: 'JSON' },
|
||||||
|
{ id: 'toml', label: 'TOML' },
|
||||||
|
{ id: 'ini', label: 'INI' },
|
||||||
|
{ id: 'docker', label: 'Dockerfile' },
|
||||||
|
{ id: 'nginx', label: 'Nginx' },
|
||||||
|
{ id: 'hcl', label: 'HCL / Terraform' },
|
||||||
|
{ id: 'xml', label: 'XML' },
|
||||||
|
{ id: 'csv', label: 'CSV' },
|
||||||
|
{ id: 'graphql', label: 'GraphQL' },
|
||||||
|
{ id: 'protobuf', label: 'Protocol Buffers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Shell & Betrieb',
|
||||||
|
options: [
|
||||||
|
{ id: 'bash', label: 'Bash / Shell' },
|
||||||
|
{ id: 'powershell', label: 'PowerShell' },
|
||||||
|
{ id: 'makefile', label: 'Makefile' },
|
||||||
|
{ id: 'systemd', label: 'Systemd' },
|
||||||
|
{ id: 'vim', label: 'Vim' },
|
||||||
|
{ id: 'diff', label: 'Diff' },
|
||||||
|
{ id: 'git', label: 'Git' },
|
||||||
|
{ id: 'log', label: 'Log' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Web & Frontend',
|
||||||
|
options: [
|
||||||
|
{ id: 'javascript', label: 'JavaScript' },
|
||||||
|
{ id: 'typescript', label: 'TypeScript' },
|
||||||
|
{ id: 'jsx', label: 'JSX' },
|
||||||
|
{ id: 'tsx', label: 'TSX' },
|
||||||
|
{ id: 'markup', label: 'HTML / Markup' },
|
||||||
|
{ id: 'html', label: 'HTML' },
|
||||||
|
{ id: 'css', label: 'CSS' },
|
||||||
|
{ id: 'scss', label: 'SCSS' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Skriptsprachen',
|
||||||
|
options: [
|
||||||
|
{ id: 'python', label: 'Python' },
|
||||||
|
{ id: 'php', label: 'PHP' },
|
||||||
|
{ id: 'ruby', label: 'Ruby' },
|
||||||
|
{ id: 'lua', label: 'Lua' },
|
||||||
|
{ id: 'perl', label: 'Perl' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'System & Backend',
|
||||||
|
options: [
|
||||||
|
{ id: 'go', label: 'Go' },
|
||||||
|
{ id: 'rust', label: 'Rust' },
|
||||||
|
{ id: 'sql', label: 'SQL' },
|
||||||
|
{ id: 'c', label: 'C' },
|
||||||
|
{ id: 'cpp', label: 'C++' },
|
||||||
|
{ id: 'csharp', label: 'C#' },
|
||||||
|
{ id: 'java', label: 'Java' },
|
||||||
|
{ id: 'kotlin', label: 'Kotlin' },
|
||||||
|
{ id: 'swift', label: 'Swift' },
|
||||||
|
{ id: 'dart', label: 'Dart' },
|
||||||
|
{ id: 'zig', label: 'Zig' },
|
||||||
|
{ id: 'elixir', label: 'Elixir' },
|
||||||
|
{ id: 'scala', label: 'Scala' },
|
||||||
|
{ id: 'haskell', label: 'Haskell' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dokumentation & Text',
|
||||||
|
options: [
|
||||||
|
{ id: 'markdown', label: 'Markdown' },
|
||||||
|
{ id: 'latex', label: 'LaTeX' },
|
||||||
|
{ id: 'regex', label: 'Regex' },
|
||||||
|
{ id: 'text', label: 'Plain Text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options);
|
||||||
|
|
||||||
|
const KNOWN_OPTION_IDS = new Set<string>(CODE_LANGUAGES.map((l) => l.id));
|
||||||
|
|
||||||
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
|
yml: 'yaml',
|
||||||
|
sh: 'bash',
|
||||||
|
shell: 'bash',
|
||||||
|
zsh: 'bash',
|
||||||
|
js: 'javascript',
|
||||||
|
mjs: 'javascript',
|
||||||
|
cjs: 'javascript',
|
||||||
|
ts: 'typescript',
|
||||||
|
mts: 'typescript',
|
||||||
|
cts: 'typescript',
|
||||||
|
py: 'python',
|
||||||
|
rb: 'ruby',
|
||||||
|
rs: 'rust',
|
||||||
|
cs: 'csharp',
|
||||||
|
'c#': 'csharp',
|
||||||
|
dotnet: 'csharp',
|
||||||
|
'c++': 'cpp',
|
||||||
|
cxx: 'cpp',
|
||||||
|
cc: 'cpp',
|
||||||
|
hpp: 'cpp',
|
||||||
|
h: 'c',
|
||||||
|
kt: 'kotlin',
|
||||||
|
kts: 'kotlin',
|
||||||
|
hs: 'haskell',
|
||||||
|
md: 'markdown',
|
||||||
|
tex: 'latex',
|
||||||
|
dockerfile: 'docker',
|
||||||
|
containerfile: 'docker',
|
||||||
|
htm: 'html',
|
||||||
|
svg: 'xml',
|
||||||
|
proto: 'protobuf',
|
||||||
|
ps1: 'powershell',
|
||||||
|
pwsh: 'powershell',
|
||||||
|
make: 'makefile',
|
||||||
|
gql: 'graphql',
|
||||||
|
tf: 'hcl',
|
||||||
|
terraform: 'hcl',
|
||||||
|
ex: 'elixir',
|
||||||
|
exs: 'elixir',
|
||||||
|
pl: 'perl',
|
||||||
|
conf: 'ini',
|
||||||
|
golang: 'go',
|
||||||
|
plaintext: 'text',
|
||||||
|
plain: 'text',
|
||||||
|
txt: 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHORT_PREFIX_CANDIDATES = new Set<string>([
|
||||||
|
'c',
|
||||||
|
'go',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'sh',
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'rs',
|
||||||
|
'cs',
|
||||||
|
'kt',
|
||||||
|
'hs',
|
||||||
|
'md',
|
||||||
|
'ex',
|
||||||
|
'pl',
|
||||||
|
'tf',
|
||||||
|
'tex',
|
||||||
|
'htm',
|
||||||
|
'yml',
|
||||||
|
'doc',
|
||||||
|
'docker',
|
||||||
|
'make',
|
||||||
|
'proto',
|
||||||
|
'plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function resolveLanguageId(raw?: string): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const key = raw.trim().toLowerCase();
|
||||||
|
if (!key) return undefined;
|
||||||
|
if (KNOWN_OPTION_IDS.has(key)) return key;
|
||||||
|
return LANGUAGE_ALIASES[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a language token in `/code <lang>` or ````<lang>` cannot be a prefix of another language user is still typing
|
||||||
|
export function isUnambiguousLanguageShortcut(raw?: string): boolean {
|
||||||
|
if (!raw) return false;
|
||||||
|
const key = raw.trim().toLowerCase();
|
||||||
|
if (SHORT_PREFIX_CANDIDATES.has(key)) return false;
|
||||||
|
return Boolean(resolveLanguageId(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// On-demand loaders for Prism grammars (Vite code-splits each into its own chunk)
|
||||||
|
const PRISM_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||||
|
yaml: () => import('prismjs/components/prism-yaml'),
|
||||||
|
json: () => import('prismjs/components/prism-json'),
|
||||||
|
toml: () => import('prismjs/components/prism-toml'),
|
||||||
|
ini: () => import('prismjs/components/prism-ini'),
|
||||||
|
docker: () => import('prismjs/components/prism-docker'),
|
||||||
|
nginx: () => import('prismjs/components/prism-nginx'),
|
||||||
|
hcl: () => import('prismjs/components/prism-hcl'),
|
||||||
|
csv: () => import('prismjs/components/prism-csv'),
|
||||||
|
graphql: () => import('prismjs/components/prism-graphql'),
|
||||||
|
protobuf: () => import('prismjs/components/prism-protobuf'),
|
||||||
|
bash: () => import('prismjs/components/prism-bash'),
|
||||||
|
powershell: () => import('prismjs/components/prism-powershell'),
|
||||||
|
makefile: () => import('prismjs/components/prism-makefile'),
|
||||||
|
systemd: () => import('prismjs/components/prism-systemd'),
|
||||||
|
vim: () => import('prismjs/components/prism-vim'),
|
||||||
|
diff: () => import('prismjs/components/prism-diff'),
|
||||||
|
git: () => import('prismjs/components/prism-git'),
|
||||||
|
log: () => import('prismjs/components/prism-log'),
|
||||||
|
typescript: () => import('prismjs/components/prism-typescript'),
|
||||||
|
jsx: () => import('prismjs/components/prism-jsx'),
|
||||||
|
tsx: () => import('prismjs/components/prism-tsx'),
|
||||||
|
scss: () => import('prismjs/components/prism-scss'),
|
||||||
|
python: () => import('prismjs/components/prism-python'),
|
||||||
|
'markup-templating': () => import('prismjs/components/prism-markup-templating'),
|
||||||
|
php: () => import('prismjs/components/prism-php'),
|
||||||
|
ruby: () => import('prismjs/components/prism-ruby'),
|
||||||
|
lua: () => import('prismjs/components/prism-lua'),
|
||||||
|
perl: () => import('prismjs/components/prism-perl'),
|
||||||
|
go: () => import('prismjs/components/prism-go'),
|
||||||
|
rust: () => import('prismjs/components/prism-rust'),
|
||||||
|
sql: () => import('prismjs/components/prism-sql'),
|
||||||
|
c: () => import('prismjs/components/prism-c'),
|
||||||
|
cpp: () => import('prismjs/components/prism-cpp'),
|
||||||
|
csharp: () => import('prismjs/components/prism-csharp'),
|
||||||
|
java: () => import('prismjs/components/prism-java'),
|
||||||
|
kotlin: () => import('prismjs/components/prism-kotlin'),
|
||||||
|
swift: () => import('prismjs/components/prism-swift'),
|
||||||
|
dart: () => import('prismjs/components/prism-dart'),
|
||||||
|
zig: () => import('prismjs/components/prism-zig'),
|
||||||
|
elixir: () => import('prismjs/components/prism-elixir'),
|
||||||
|
scala: () => import('prismjs/components/prism-scala'),
|
||||||
|
haskell: () => import('prismjs/components/prism-haskell'),
|
||||||
|
markdown: () => import('prismjs/components/prism-markdown'),
|
||||||
|
latex: () => import('prismjs/components/prism-latex'),
|
||||||
|
regex: () => import('prismjs/components/prism-regex'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prerequisite grammars that must be loaded in order before the target grammar
|
||||||
|
const PRISM_DEPENDENCIES: Record<string, string[]> = {
|
||||||
|
tsx: ['jsx', 'typescript'],
|
||||||
|
cpp: ['c'],
|
||||||
|
php: ['markup-templating'],
|
||||||
|
scala: ['java'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const inFlightGrammarLoads = new Map<string, Promise<boolean>>();
|
||||||
|
|
||||||
|
export function toPrismGrammarKey(lang?: string): string {
|
||||||
|
if (!lang) return '';
|
||||||
|
const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase();
|
||||||
|
if (!resolved || resolved === 'text' || resolved === 'plaintext') return '';
|
||||||
|
if (resolved === 'html') return 'markup';
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPrismLanguage(lang?: string): Promise<boolean> {
|
||||||
|
const key = toPrismGrammarKey(lang);
|
||||||
|
if (!key) return false;
|
||||||
|
if (Prism.languages[key]) return true;
|
||||||
|
|
||||||
|
const existing = inFlightGrammarLoads.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
try {
|
||||||
|
const deps = PRISM_DEPENDENCIES[key];
|
||||||
|
if (deps) {
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (!Prism.languages[dep]) {
|
||||||
|
await loadPrismLanguage(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loader = PRISM_LOADERS[key];
|
||||||
|
if (loader) {
|
||||||
|
await loader();
|
||||||
|
}
|
||||||
|
return Boolean(Prism.languages[key]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
inFlightGrammarLoads.delete(key);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlightGrammarLoads.set(key, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeBlockEditorProps {
|
||||||
|
block: DocumentBlock;
|
||||||
|
content?: string;
|
||||||
|
canEdit?: boolean;
|
||||||
|
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||||
|
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
|
onChangeContent?: (content: string) => void;
|
||||||
|
onChangeLang?: (lang: string) => void;
|
||||||
|
onConvertToText?: () => void;
|
||||||
|
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||||
|
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||||
|
handleContentChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||||
|
block,
|
||||||
|
content,
|
||||||
|
canEdit = true,
|
||||||
|
textareaRef,
|
||||||
|
registerTextareaRef,
|
||||||
|
onChangeContent,
|
||||||
|
onChangeLang,
|
||||||
|
onConvertToText,
|
||||||
|
handleFocus,
|
||||||
|
handleBlur,
|
||||||
|
handleContentChange,
|
||||||
|
handleKeyDown,
|
||||||
|
}) => {
|
||||||
|
const isControlled = content !== undefined;
|
||||||
|
const [uncontrolledContent, setUncontrolledContent] = useState<string>(block.content || '');
|
||||||
|
const currentContent = isControlled ? content : uncontrolledContent;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledContent(block.content || '');
|
||||||
|
}
|
||||||
|
}, [block.content, isControlled]);
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loadedGrammar, setLoadedGrammar] = useState<string>('');
|
||||||
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
|
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const preRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(copyTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prismLang = toPrismGrammarKey(block.lang);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prismLang) return;
|
||||||
|
if (Prism.languages[prismLang]) {
|
||||||
|
setLoadedGrammar(prismLang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
loadPrismLanguage(prismLang).then((loaded) => {
|
||||||
|
if (!cancelled && loaded) {
|
||||||
|
setLoadedGrammar(prismLang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [prismLang]);
|
||||||
|
|
||||||
|
const highlightedHtml = useMemo(() => {
|
||||||
|
if (!currentContent) return '';
|
||||||
|
const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : '';
|
||||||
|
if (!activeLang) return escapeHtml(currentContent);
|
||||||
|
const grammar = Prism.languages[activeLang];
|
||||||
|
if (!grammar) return escapeHtml(currentContent);
|
||||||
|
try {
|
||||||
|
return Prism.highlight(currentContent, grammar, activeLang);
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(currentContent);
|
||||||
|
}
|
||||||
|
}, [currentContent, prismLang, loadedGrammar]);
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const el = localTextareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [currentContent, adjustHeight]);
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const p = navigator.clipboard?.writeText(currentContent);
|
||||||
|
if (p && typeof p.catch === 'function') {
|
||||||
|
p.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore clipboard errors
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
if (copyTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(copyTimerRef.current);
|
||||||
|
}
|
||||||
|
copyTimerRef.current = window.setTimeout(() => {
|
||||||
|
copyTimerRef.current = null;
|
||||||
|
setCopied(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledContent(newVal);
|
||||||
|
}
|
||||||
|
if (onChangeContent) {
|
||||||
|
onChangeContent(newVal);
|
||||||
|
} else if (handleContentChange) {
|
||||||
|
target.value = newVal;
|
||||||
|
handleContentChange({
|
||||||
|
target,
|
||||||
|
currentTarget: target,
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledContent(e.target.value);
|
||||||
|
}
|
||||||
|
if (handleContentChange) {
|
||||||
|
handleContentChange(e);
|
||||||
|
} else if (onChangeContent) {
|
||||||
|
onChangeContent(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (canEdit && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const ta = e.currentTarget;
|
||||||
|
const val = currentContent;
|
||||||
|
const start = ta.selectionStart ?? 0;
|
||||||
|
const end = ta.selectionEnd ?? start;
|
||||||
|
|
||||||
|
const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end;
|
||||||
|
const lineEndIdx = val.indexOf('\n', effectiveEnd);
|
||||||
|
const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx;
|
||||||
|
const lines = val.slice(lineStart, blockEnd).split('\n');
|
||||||
|
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
const updatedLines = lines.map((line) => ' ' + line);
|
||||||
|
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
|
||||||
|
const newStart = start + 2;
|
||||||
|
const newEnd = end + updatedLines.length * 2;
|
||||||
|
applyContentUpdate(newVal, ta);
|
||||||
|
ta.value = newVal;
|
||||||
|
ta.setSelectionRange(newStart, newEnd);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.setSelectionRange(newStart, newEnd);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
let removedFirst = 0;
|
||||||
|
let removedTotal = 0;
|
||||||
|
const updatedLines = lines.map((line, idx) => {
|
||||||
|
let removeCount = 0;
|
||||||
|
if (line.startsWith(' ')) removeCount = 2;
|
||||||
|
else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1;
|
||||||
|
if (idx === 0) removedFirst = removeCount;
|
||||||
|
removedTotal += removeCount;
|
||||||
|
return line.slice(removeCount);
|
||||||
|
});
|
||||||
|
if (removedTotal > 0) {
|
||||||
|
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
|
||||||
|
const newStart = Math.max(lineStart, start - removedFirst);
|
||||||
|
const newEnd = Math.max(newStart, end - removedTotal);
|
||||||
|
applyContentUpdate(newVal, ta);
|
||||||
|
ta.value = newVal;
|
||||||
|
ta.setSelectionRange(newStart, newEnd);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.setSelectionRange(newStart, newEnd);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (preRef.current) {
|
||||||
|
preRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||||
|
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawLang = block.lang || '';
|
||||||
|
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
||||||
|
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
|
||||||
|
{/* Code Block Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||||
|
{canEdit ? (
|
||||||
|
<select
|
||||||
|
aria-label="Sprache auswählen"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(e) => onChangeLang?.(e.target.value)}
|
||||||
|
className="min-h-[44px] sm:min-h-0 bg-transparent text-muted hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1.5 py-0.5 outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="" className="bg-panel text-main">
|
||||||
|
Text
|
||||||
|
</option>
|
||||||
|
{hasCustomUnknownLang && (
|
||||||
|
<option value={selectValue} className="bg-panel text-main">
|
||||||
|
{selectValue}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{CODE_LANGUAGE_GROUPS.map((group) => (
|
||||||
|
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
|
||||||
|
{group.options.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id} className="bg-panel text-main">
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-main hover:bg-panel-elevated transition-colors inline-flex items-center gap-1 cursor-pointer"
|
||||||
|
title="In die Zwischenablage kopieren"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 text-status-ok" />
|
||||||
|
<span className="text-status-ok">Kopiert!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3 h-3 opacity-75" />
|
||||||
|
<span>Kopieren</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canEdit && onConvertToText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConvertToText}
|
||||||
|
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-accent hover:bg-panel-elevated transition-colors inline-flex items-center cursor-pointer"
|
||||||
|
>
|
||||||
|
Als Text
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Highlighted Overlay + Textarea */}
|
||||||
|
<div className="relative w-full overflow-x-auto">
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
localTextareaRef.current = el;
|
||||||
|
if (textareaRef) textareaRef.current = el;
|
||||||
|
registerTextareaRef?.(block.id, el);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={currentContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={onTextareaChange}
|
||||||
|
onKeyDown={onTextareaKeyDown}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ caretColor: 'var(--text-main)' }}
|
||||||
|
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import React, { useState, useMemo, useRef } from 'react';
|
|
||||||
import Prism from 'prismjs';
|
|
||||||
import 'prismjs/components/prism-python';
|
|
||||||
import 'prismjs/components/prism-typescript';
|
|
||||||
import 'prismjs/components/prism-bash';
|
|
||||||
import 'prismjs/components/prism-json';
|
|
||||||
import 'prismjs/components/prism-go';
|
|
||||||
import 'prismjs/components/prism-sql';
|
|
||||||
import 'prismjs/components/prism-yaml';
|
|
||||||
import 'prismjs/components/prism-rust';
|
|
||||||
import 'prismjs/components/prism-c';
|
|
||||||
import 'prismjs/components/prism-cpp';
|
|
||||||
import 'prismjs/components/prism-java';
|
|
||||||
import 'prismjs/components/prism-php';
|
|
||||||
import 'prismjs/components/prism-docker';
|
|
||||||
import 'prismjs/components/prism-markdown';
|
|
||||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
|
||||||
import { DocumentBlock } from '../../lib/types';
|
|
||||||
|
|
||||||
export interface CodeBlockItemProps {
|
|
||||||
block: DocumentBlock;
|
|
||||||
isReadOnly: boolean;
|
|
||||||
onChangeContent: (content: string) => void;
|
|
||||||
onChangeLang: (lang: string) => void;
|
|
||||||
onFocus?: () => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CODE_LANGUAGES = [
|
|
||||||
{ id: 'javascript', label: 'JavaScript' },
|
|
||||||
{ id: 'typescript', label: 'TypeScript' },
|
|
||||||
{ id: 'python', label: 'Python' },
|
|
||||||
{ id: 'go', label: 'Go' },
|
|
||||||
{ id: 'bash', label: 'Bash / Shell' },
|
|
||||||
{ id: 'json', label: 'JSON' },
|
|
||||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
|
||||||
{ id: 'css', label: 'CSS' },
|
|
||||||
{ id: 'sql', label: 'SQL' },
|
|
||||||
{ id: 'rust', label: 'Rust' },
|
|
||||||
{ id: 'cpp', label: 'C++' },
|
|
||||||
{ id: 'c', label: 'C' },
|
|
||||||
{ id: 'java', label: 'Java' },
|
|
||||||
{ id: 'php', label: 'PHP' },
|
|
||||||
{ id: 'yaml', label: 'YAML' },
|
|
||||||
{ id: 'docker', label: 'Dockerfile' },
|
|
||||||
{ id: 'markdown', label: 'Markdown' },
|
|
||||||
{ id: 'text', label: 'Plain Text' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
|
||||||
block,
|
|
||||||
isReadOnly,
|
|
||||||
onChangeContent,
|
|
||||||
onChangeLang,
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
inputRef,
|
|
||||||
}) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
const currentLang = block.lang || 'javascript';
|
|
||||||
|
|
||||||
// Highlighted HTML generated by Prism
|
|
||||||
const highlightedCode = useMemo(() => {
|
|
||||||
const code = block.content || '';
|
|
||||||
if (!code) return '';
|
|
||||||
|
|
||||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
|
||||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
|
||||||
if (!grammar) {
|
|
||||||
return code
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Prism.highlight(code, grammar, prismLang);
|
|
||||||
} catch {
|
|
||||||
return code
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
}, [block.content, currentLang]);
|
|
||||||
|
|
||||||
// Code lines for line numbers
|
|
||||||
const lines = useMemo(() => {
|
|
||||||
return (block.content || '').split('\n');
|
|
||||||
}, [block.content]);
|
|
||||||
|
|
||||||
const handleCopy = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(block.content || '');
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// Fallback
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setIsEditing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Tab key indent/outdent
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const textarea = e.currentTarget;
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const val = textarea.value;
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (val.substring(start - 2, start) === ' ') {
|
|
||||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
|
||||||
onChangeContent(newVal);
|
|
||||||
setTimeout(() => {
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
|
||||||
onChangeContent(newVal);
|
|
||||||
setTimeout(() => {
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchToEdit = () => {
|
|
||||||
if (isReadOnly) return;
|
|
||||||
setIsEditing(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
localTextareaRef.current?.focus();
|
|
||||||
}, 20);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
|
||||||
{/* Code Block Header Toolbar */}
|
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
|
||||||
{/* Left: Language Selection */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
|
||||||
<select
|
|
||||||
value={currentLang}
|
|
||||||
disabled={isReadOnly}
|
|
||||||
onChange={(e) => onChangeLang(e.target.value)}
|
|
||||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
|
||||||
>
|
|
||||||
{CODE_LANGUAGES.map((lang) => (
|
|
||||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
|
||||||
{lang.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Mode Toggle & Copy */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{!isReadOnly && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (isEditing) {
|
|
||||||
setIsEditing(false);
|
|
||||||
} else {
|
|
||||||
switchToEdit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
|
||||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
|
||||||
>
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<Eye className="w-3 h-3 text-accent" />
|
|
||||||
<span>Vorschau</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Edit3 className="w-3 h-3 text-muted" />
|
|
||||||
<span>Bearbeiten</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
|
||||||
copied
|
|
||||||
? 'text-emerald-500 bg-emerald-500/10'
|
|
||||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
|
||||||
}`}
|
|
||||||
title="Code in Zwischenablage kopieren"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="w-3 h-3 text-emerald-500" />
|
|
||||||
<span>Kopiert!</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-3 h-3 opacity-70" />
|
|
||||||
<span>Kopieren</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Code Block Content */}
|
|
||||||
{isEditing && !isReadOnly ? (
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
ref={(el) => {
|
|
||||||
localTextareaRef.current = el;
|
|
||||||
if (inputRef) inputRef(el);
|
|
||||||
}}
|
|
||||||
rows={Math.max(3, lines.length)}
|
|
||||||
value={block.content || ''}
|
|
||||||
onFocus={() => onFocus && onFocus()}
|
|
||||||
onBlur={() => {
|
|
||||||
if (onBlur) onBlur();
|
|
||||||
}}
|
|
||||||
onChange={(e) => onChangeContent(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="// Code hier eingeben..."
|
|
||||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
|
||||||
/>
|
|
||||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
|
||||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditing(false)}
|
|
||||||
className="hover:text-accent cursor-pointer"
|
|
||||||
>
|
|
||||||
Vorschau anzeigen (Esc)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
onClick={switchToEdit}
|
|
||||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
|
||||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
|
||||||
}`}
|
|
||||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
|
||||||
>
|
|
||||||
{block.content ? (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* Line numbers */}
|
|
||||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
|
||||||
{lines.map((_, idx) => (
|
|
||||||
<div key={idx}>{idx + 1}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Syntax highlighted code */}
|
|
||||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
|
||||||
<code
|
|
||||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
|
||||||
className={`language-${currentLang}`}
|
|
||||||
/>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-faint italic py-2">
|
|
||||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
|
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
|
||||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { safeHref } from '../../lib/safeUrl';
|
import { safeHref } from '../../lib/safeUrl';
|
||||||
@@ -313,22 +314,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (slashSearch === null) return [];
|
||||||
const q = slashSearch.toLowerCase();
|
const q = slashSearch.toLowerCase();
|
||||||
|
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
||||||
return SLASH_COMMANDS.filter(
|
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]);
|
}, [slashSearch]);
|
||||||
|
|
||||||
const applySlashCommand = (cmd: SlashCommand) => {
|
const applySlashCommand = (cmd: SlashCommand) => {
|
||||||
setSlashSearch(null);
|
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') {
|
if (cmd.type === 'link') {
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
setShowLinkModal(true);
|
setShowLinkModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const langOverride =
|
||||||
|
cmd.type === 'code' && codeLangMatch
|
||||||
|
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||||
|
: undefined;
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
|
localContentRef.current = clean;
|
||||||
flushDebounce(clean, {
|
flushDebounce(clean, {
|
||||||
type: cmd.type,
|
type: cmd.type,
|
||||||
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -348,9 +357,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
let val = e.target.value;
|
let val = e.target.value;
|
||||||
let typeOverride: BlockType | undefined;
|
let typeOverride: BlockType | undefined;
|
||||||
let checkedOverride: boolean | undefined;
|
let checkedOverride: boolean | undefined;
|
||||||
|
let langOverride: string | undefined;
|
||||||
|
|
||||||
|
if (block.type !== 'code') {
|
||||||
// Quick markdown shortcuts
|
// Quick markdown shortcuts
|
||||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
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);
|
||||||
|
|
||||||
|
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/, '');
|
val = val.replace(/^\[\s?\]\s/, '');
|
||||||
typeOverride = 'todo';
|
typeOverride = 'todo';
|
||||||
checkedOverride = false;
|
checkedOverride = false;
|
||||||
@@ -370,15 +404,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for slash command invocation
|
// Check for slash command invocation
|
||||||
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
|
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||||
if (slashMatch) {
|
if (slashMatch) {
|
||||||
setSlashSearch(slashMatch[1]);
|
setSlashSearch(slashMatch[1]);
|
||||||
setSlashIndex(0);
|
setSlashIndex(0);
|
||||||
} else {
|
} else {
|
||||||
setSlashSearch(null);
|
setSlashSearch(null);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setSlashSearch(null);
|
||||||
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
|
localContentRef.current = val;
|
||||||
|
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
@@ -387,6 +425,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (typeOverride) {
|
if (typeOverride) {
|
||||||
flushDebounce(val, {
|
flushDebounce(val, {
|
||||||
type: typeOverride,
|
type: typeOverride,
|
||||||
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -533,6 +572,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
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;
|
const minLevel = levelOffset || 0;
|
||||||
if (localContent === '') {
|
if (localContent === '') {
|
||||||
if (Math.max(0, block.level || 0) > minLevel) {
|
if (Math.max(0, block.level || 0) > minLevel) {
|
||||||
@@ -749,32 +802,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<hr className="border-t border-border" />
|
<hr className="border-t border-border" />
|
||||||
</div>
|
</div>
|
||||||
) : block.type === 'code' ? (
|
) : block.type === 'code' ? (
|
||||||
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
<CodeBlockEditor
|
||||||
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
block={block}
|
||||||
<span>Code</span>
|
content={localContent}
|
||||||
<button
|
canEdit={canEdit}
|
||||||
type="button"
|
textareaRef={textareaRef}
|
||||||
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
registerTextareaRef={registerTextareaRef}
|
||||||
className="hover:text-accent"
|
onChangeContent={(newContent) => {
|
||||||
>
|
setLocalContent(newContent);
|
||||||
Als Text
|
localContentRef.current = newContent;
|
||||||
</button>
|
flushDebounce(newContent);
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref={(el) => {
|
|
||||||
textareaRef.current = el;
|
|
||||||
registerTextareaRef(block.id, el);
|
|
||||||
}}
|
}}
|
||||||
rows={1}
|
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
|
||||||
value={localContent}
|
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||||
readOnly={!canEdit}
|
handleFocus={handleFocus}
|
||||||
onFocus={handleFocus}
|
handleBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
handleContentChange={handleContentChange}
|
||||||
onChange={handleContentChange}
|
handleKeyDown={handleKeyDown}
|
||||||
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>
|
|
||||||
) : block.type === 'callout' ? (
|
) : block.type === 'callout' ? (
|
||||||
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
|
||||||
|
import { CodeBlockEditor, loadPrismLanguage } from '../CodeBlockEditor';
|
||||||
|
import { OutlinerBlock } from '../OutlinerBlock';
|
||||||
|
import { DocumentBlock } from '../../../lib/types';
|
||||||
|
|
||||||
|
function makeCodeBlock(overrides: Partial<DocumentBlock> = {}): DocumentBlock {
|
||||||
|
return {
|
||||||
|
id: 'code-1',
|
||||||
|
type: 'code',
|
||||||
|
content: 'name: mindsift\nport: 8080',
|
||||||
|
level: 0,
|
||||||
|
position: 1000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutlinerCodeBlock(
|
||||||
|
initialBlock: DocumentBlock,
|
||||||
|
props: Partial<React.ComponentProps<typeof OutlinerBlock>> = {}
|
||||||
|
) {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const indentCalls: Array<[string, 1 | -1]> = [];
|
||||||
|
|
||||||
|
const Wrapper: React.FC = () => {
|
||||||
|
const [blk, setBlk] = useState<DocumentBlock>(initialBlock);
|
||||||
|
return (
|
||||||
|
<OutlinerBlock
|
||||||
|
docId="doc-1"
|
||||||
|
block={blk}
|
||||||
|
index={0}
|
||||||
|
isLast
|
||||||
|
maxAllowedLevel={1}
|
||||||
|
hasChildren={false}
|
||||||
|
isFocused={false}
|
||||||
|
canEdit={props.canEdit ?? true}
|
||||||
|
onUpdateBlock={(updated, immediate) => {
|
||||||
|
updates.push(updated);
|
||||||
|
setBlk(updated);
|
||||||
|
props.onUpdateBlock?.(updated, immediate);
|
||||||
|
}}
|
||||||
|
onCreateBlockBelow={props.onCreateBlockBelow || (() => {})}
|
||||||
|
onDeleteBlock={props.onDeleteBlock || (() => {})}
|
||||||
|
onIndentBlock={(id, delta) => {
|
||||||
|
indentCalls.push([id, delta]);
|
||||||
|
props.onIndentBlock?.(id, delta);
|
||||||
|
}}
|
||||||
|
onNavigateFocus={props.onNavigateFocus || (() => {})}
|
||||||
|
registerTextareaRef={props.registerTextareaRef || (() => {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const utils = render(<Wrapper />);
|
||||||
|
return { ...utils, updates, indentCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodeBlockEditor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls update with lang when selecting a language and hides the picker for readers', () => {
|
||||||
|
const { container, updates, unmount } = renderOutlinerCodeBlock(makeCodeBlock({ lang: undefined }));
|
||||||
|
|
||||||
|
const select = container.querySelector('select') as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect(select.value).toBe('');
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: 'yaml' } });
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0].lang).toBe('yaml');
|
||||||
|
expect(select.value).toBe('yaml');
|
||||||
|
|
||||||
|
// "Als Text" converts the code block back to a bullet
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Als Text' }));
|
||||||
|
expect(updates[updates.length - 1]?.type).toBe('bullet');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
|
||||||
|
const readerView = render(
|
||||||
|
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
|
||||||
|
);
|
||||||
|
expect(readerView.container.querySelector('select')).toBeNull();
|
||||||
|
expect(readerView.getByText('yaml')).toBeTruthy();
|
||||||
|
expect(readerView.queryByText('Als Text')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: writeTextMock },
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const block = makeCodeBlock({ content: 'echo "hello world"' });
|
||||||
|
render(<CodeBlockEditor block={block} content={block.content} canEdit />);
|
||||||
|
|
||||||
|
const copyBtn = screen.getByRole('button', { name: /Kopieren/i });
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
|
||||||
|
expect(screen.getByText('Kopiert!')).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('Kopiert!')).toBeNull();
|
||||||
|
expect(screen.getByText('Kopieren')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
|
||||||
|
const { container, updates, indentCalls } = renderOutlinerCodeBlock(
|
||||||
|
makeCodeBlock({ content: 'key: value', lang: 'yaml' })
|
||||||
|
);
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(0, 0);
|
||||||
|
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Tab' });
|
||||||
|
expect(textarea.value).toBe(' key: value');
|
||||||
|
expect(updates[updates.length - 1]?.content).toBe(' key: value');
|
||||||
|
expect(indentCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
|
||||||
|
expect(textarea.value).toBe('key: value');
|
||||||
|
expect(updates[updates.length - 1]?.content).toBe('key: value');
|
||||||
|
expect(indentCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('indents and outdents multiple selected lines on Tab and Shift+Tab', () => {
|
||||||
|
const { container } = renderOutlinerCodeBlock(
|
||||||
|
makeCodeBlock({ content: 'first: 1\nsecond: 2', lang: 'yaml' })
|
||||||
|
);
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Tab' });
|
||||||
|
expect(textarea.value).toBe(' first: 1\n second: 2');
|
||||||
|
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
|
||||||
|
expect(textarea.value).toBe('first: 1\nsecond: 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Prism .token spans for yaml and escapes HTML tags like <script>', async () => {
|
||||||
|
const code = 'title: "<script>alert(1)</script>"\nenabled: true\ncount: 42';
|
||||||
|
const block = makeCodeBlock({ content: code, lang: 'yaml' });
|
||||||
|
const { container } = render(<CodeBlockEditor block={block} content={code} canEdit />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelectorAll('.token').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pre = container.querySelector('pre') as HTMLPreElement;
|
||||||
|
const codeEl = pre.querySelector('code') as HTMLElement;
|
||||||
|
expect(pre.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(codeEl.querySelectorAll('.token').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ensure no real <script> element was injected into the DOM and raw HTML is escaped
|
||||||
|
expect(pre.querySelector('script')).toBeNull();
|
||||||
|
expect(codeEl.innerHTML).toContain('<script>');
|
||||||
|
expect(pre.textContent).toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads dependent grammars in the right order (tsx, cpp, php, scala)', async () => {
|
||||||
|
expect(await loadPrismLanguage('tsx')).toBe(true);
|
||||||
|
expect(await loadPrismLanguage('cpp')).toBe(true);
|
||||||
|
expect(await loadPrismLanguage('php')).toBe(true);
|
||||||
|
expect(await loadPrismLanguage('scala')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lang directly when typing /code yaml or ```yaml in an empty bullet block', () => {
|
||||||
|
const bulletBlock: DocumentBlock = {
|
||||||
|
id: 'b-1',
|
||||||
|
type: 'bullet',
|
||||||
|
content: '',
|
||||||
|
level: 0,
|
||||||
|
position: 1000,
|
||||||
|
};
|
||||||
|
const { container, updates } = renderOutlinerCodeBlock(bulletBlock);
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: '/code yaml' } });
|
||||||
|
expect(updates[updates.length - 1]?.type).toBe('code');
|
||||||
|
expect(updates[updates.length - 1]?.lang).toBe('yaml');
|
||||||
|
expect(updates[updates.length - 1]?.content).toBe('');
|
||||||
|
|
||||||
|
// Also test ```json shortcut
|
||||||
|
const bulletBlock2: DocumentBlock = {
|
||||||
|
id: 'b-2',
|
||||||
|
type: 'bullet',
|
||||||
|
content: '',
|
||||||
|
level: 0,
|
||||||
|
position: 2000,
|
||||||
|
};
|
||||||
|
const res2 = renderOutlinerCodeBlock(bulletBlock2);
|
||||||
|
const ta2 = res2.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(ta2, { target: { value: '```json' } });
|
||||||
|
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
||||||
|
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
||||||
|
});
|
||||||
|
});
|
||||||
+77
-33
@@ -411,62 +411,106 @@ body {
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prism Syntax Highlighting Tokens */
|
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables) */
|
||||||
|
:root {
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #d4d4d4;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not(.dark),
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--code-keyword: #0000ff;
|
||||||
|
--code-string: #a31515;
|
||||||
|
--code-number: #098658;
|
||||||
|
--code-comment: #008000;
|
||||||
|
--code-property: #0451a5;
|
||||||
|
--code-function: #795e26;
|
||||||
|
--code-type: #267f99;
|
||||||
|
--code-punctuation: #3b3b3b;
|
||||||
|
--code-variable: #001080;
|
||||||
|
}
|
||||||
|
|
||||||
.token.comment,
|
.token.comment,
|
||||||
.token.prolog,
|
.token.prolog,
|
||||||
.token.doctype,
|
.token.doctype,
|
||||||
.token.cdata {
|
.token.cdata {
|
||||||
color: #71717a;
|
color: var(--code-comment);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.punctuation {
|
.token.punctuation,
|
||||||
color: #94a3b8;
|
.token.operator {
|
||||||
|
color: var(--code-punctuation);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.property,
|
.token.property,
|
||||||
.token.tag,
|
.token.key,
|
||||||
.token.boolean,
|
.token.attr-name {
|
||||||
|
color: var(--code-property);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.string,
|
||||||
|
.token.char,
|
||||||
|
.token.attr-value,
|
||||||
|
.token.scalar,
|
||||||
|
.token.inserted,
|
||||||
|
.language-css .token.string,
|
||||||
|
.style .token.string {
|
||||||
|
color: var(--code-string);
|
||||||
|
}
|
||||||
|
|
||||||
.token.number,
|
.token.number,
|
||||||
.token.constant,
|
.token.constant,
|
||||||
.token.symbol,
|
.token.symbol,
|
||||||
.token.deleted {
|
.token.datetime {
|
||||||
color: #f43f5e;
|
color: var(--code-number);
|
||||||
}
|
|
||||||
|
|
||||||
.token.selector,
|
|
||||||
.token.attr-name,
|
|
||||||
.token.string,
|
|
||||||
.token.char,
|
|
||||||
.token.builtin,
|
|
||||||
.token.inserted {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.operator,
|
|
||||||
.token.entity,
|
|
||||||
.token.url,
|
|
||||||
.language-css .token.string,
|
|
||||||
.style .token.string {
|
|
||||||
color: #38bdf8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token.keyword,
|
||||||
|
.token.boolean,
|
||||||
|
.token.null,
|
||||||
.token.atrule,
|
.token.atrule,
|
||||||
.token.attr-value,
|
.token.directive,
|
||||||
.token.keyword {
|
.token.tag,
|
||||||
color: #818cf8;
|
.token.deleted {
|
||||||
font-weight: 600;
|
color: var(--code-keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.key.atrule {
|
||||||
|
color: var(--code-property);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.function,
|
.token.function,
|
||||||
.token.class-name {
|
.token.function-variable {
|
||||||
color: #fbbf24;
|
color: var(--code-function);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token.class-name,
|
||||||
|
.token.type,
|
||||||
|
.token.builtin,
|
||||||
|
.token.namespace {
|
||||||
|
color: var(--code-type);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.variable,
|
||||||
.token.regex,
|
.token.regex,
|
||||||
.token.important,
|
.token.important,
|
||||||
.token.variable {
|
.token.selector,
|
||||||
color: #f97316;
|
.token.entity,
|
||||||
|
.token.url {
|
||||||
|
color: var(--code-variable);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.boolean.important,
|
||||||
|
.token.null.important {
|
||||||
|
color: var(--code-keyword);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,4 +53,15 @@
|
|||||||
--radius-input: 2px;
|
--radius-input: 2px;
|
||||||
--radius-checkbox: 0px;
|
--radius-checkbox: 0px;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #d4d4d4;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
@@ -52,4 +52,15 @@
|
|||||||
--radius-input: 0px;
|
--radius-input: 0px;
|
||||||
--radius-checkbox: 0px;
|
--radius-checkbox: 0px;
|
||||||
--radius-pill: 0px;
|
--radius-pill: 0px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #ffffff;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
@@ -52,4 +52,15 @@
|
|||||||
--radius-input: 2px;
|
--radius-input: 2px;
|
||||||
--radius-checkbox: 0px;
|
--radius-checkbox: 0px;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Light+) --- */
|
||||||
|
--code-keyword: #0000ff;
|
||||||
|
--code-string: #a31515;
|
||||||
|
--code-number: #098658;
|
||||||
|
--code-comment: #008000;
|
||||||
|
--code-property: #0451a5;
|
||||||
|
--code-function: #795e26;
|
||||||
|
--code-type: #267f99;
|
||||||
|
--code-punctuation: #3b3b3b;
|
||||||
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
@@ -52,4 +52,15 @@
|
|||||||
--radius-input: 2px;
|
--radius-input: 2px;
|
||||||
--radius-checkbox: 0px;
|
--radius-checkbox: 0px;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #d4d4d4;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
@@ -53,4 +53,15 @@
|
|||||||
--radius-input: 6px;
|
--radius-input: 6px;
|
||||||
--radius-checkbox: 4px;
|
--radius-checkbox: 4px;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #d4d4d4;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
@@ -52,4 +52,15 @@
|
|||||||
--radius-input: 6px;
|
--radius-input: 6px;
|
||||||
--radius-checkbox: 4px;
|
--radius-checkbox: 4px;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
||||||
|
--code-keyword: #569cd6;
|
||||||
|
--code-string: #ce9178;
|
||||||
|
--code-number: #b5cea8;
|
||||||
|
--code-comment: #6a9955;
|
||||||
|
--code-property: #9cdcfe;
|
||||||
|
--code-function: #dcdcaa;
|
||||||
|
--code-type: #4ec9b0;
|
||||||
|
--code-punctuation: #d4d4d4;
|
||||||
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
@@ -54,4 +54,15 @@
|
|||||||
--radius-input: 2px; /* Eingabefelder & Dropdowns */
|
--radius-input: 2px; /* Eingabefelder & Dropdowns */
|
||||||
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
|
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
|
||||||
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
|
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+ Standardwerte) --- */
|
||||||
|
--code-keyword: #569cd6; /* Schlüsselwörter & Booleans */
|
||||||
|
--code-string: #ce9178; /* Zeichenketten (Strings) */
|
||||||
|
--code-number: #b5cea8; /* Zahlen & Konstanten */
|
||||||
|
--code-comment: #6a9955; /* Kommentare */
|
||||||
|
--code-property: #9cdcfe; /* Schlüssel (YAML/JSON Keys) & Attribute */
|
||||||
|
--code-function: #dcdcaa; /* Funktionen & Methoden */
|
||||||
|
--code-type: #4ec9b0; /* Typen & Klassen */
|
||||||
|
--code-punctuation: #d4d4d4; /* Klammern, Operatoren & Satzzeichen */
|
||||||
|
--code-variable: #9cdcfe; /* Variablen & Regex */
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
declare module 'prismjs/components/*';
|
||||||
Reference in new issue
Block a user