feat(workspace): borderless code blocks, round code/audio/dictation blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s

Code blocks lose border and header bar: language at the top left, a round
copy button at the bottom right on hover; converting back to text moves to
the bullet's action bubble (also for numbered items). Code, audio and
dictation blocks use fixed round corners whatever radius the theme sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 22:21:17 +01:00
1 parent e243e328c7
commit 546bcba666
7 files changed
+27 -53

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). - PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
### Changed ### Changed
- Code-Blöcke ohne Rahmen und Kopfleiste: oben links nur die Sprache, Kopieren als runder Symbol-Knopf unten rechts (erscheint beim Überfahren, am Handy immer). „In normalen Text umwandeln“ steht für Code-Blöcke (und nummerierte Punkte) in der Aktionsblase des Punkts.
- Code-, Audio- und Diktat-Blöcke haben unabhängig vom Theme runde Ecken; Callouts bleiben eckig.
- Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit. - Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit.
- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet). - Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet).
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber. - Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
@@ -87,7 +87,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
return ( return (
<div className="my-1 space-y-1"> <div className="my-1 space-y-1">
{failed ? ( {failed ? (
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted"> <div className="flex items-center gap-2 h-12 px-4 rounded-full border border-border bg-panel text-xs text-muted">
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht) <FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
</div> </div>
) : src ? ( ) : src ? (
@@ -99,7 +99,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
onClick={() => void transcribe()} onClick={() => void transcribe()}
disabled={transcribing} disabled={transcribing}
title="In Text umwandeln (eigene Spracherkennung)" title="In Text umwandeln (eigene Spracherkennung)"
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40" className="btn-secondary !rounded-full h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
> >
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />} {transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span> <span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
@@ -107,7 +107,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
)} )}
</div> </div>
) : ( ) : (
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64"> <div className="flex items-center h-12 px-4 rounded-full border border-border bg-panel w-64">
<Loader2 className="w-4 h-4 animate-spin text-muted" /> <Loader2 className="w-4 h-4 animate-spin text-muted" />
</div> </div>
)} )}
@@ -108,7 +108,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
cancel(); cancel();
} }
}} }}
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-card border border-border bg-panel-elevated px-3 py-2 focus:outline-none" className="my-1 inline-flex max-w-full flex-col gap-2 rounded-3xl border border-border bg-panel-elevated px-4 py-2 focus:outline-none"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{state === 'recording' && ( {state === 'recording' && (
@@ -162,7 +162,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
onClick={cancel} onClick={cancel}
disabled={state === 'uploading'} disabled={state === 'uploading'}
title="Abbrechen (Esc)" title="Abbrechen (Esc)"
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40" className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import Prism from 'prismjs'; import Prism from 'prismjs';
import { Code, Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import { DocumentBlock } from '../../lib/types'; import { DocumentBlock } from '../../lib/types';
Prism.manual = true; Prism.manual = true;
@@ -311,6 +311,7 @@ export interface CodeBlockEditorProps {
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void; registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
onChangeContent?: (content: string) => void; onChangeContent?: (content: string) => void;
onChangeLang?: (lang: string) => void; onChangeLang?: (lang: string) => void;
/** Kept for callers; converting back to text lives in the bullet's action bubble now */
onConvertToText?: () => void; onConvertToText?: () => void;
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void; handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void; handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
@@ -326,7 +327,6 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
registerTextareaRef, registerTextareaRef,
onChangeContent, onChangeContent,
onChangeLang, onChangeLang,
onConvertToText,
handleFocus, handleFocus,
handleBlur, handleBlur,
handleContentChange, handleContentChange,
@@ -542,17 +542,15 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return ( return (
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden"> <div className="group/code relative bg-code rounded-2xl my-0.5 max-w-full overflow-hidden">
{/* Code Block Header */} {/* Language at the top left; no header bar */}
<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 px-1.5 pt-1 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 ? ( {canEdit ? (
<select <select
aria-label="Sprache auswählen" aria-label="Sprache auswählen"
value={selectValue} value={selectValue}
onChange={(e) => onChangeLang?.(e.target.value)} 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" className="min-h-[44px] sm:min-h-0 bg-transparent text-faint 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 py-0.5 outline-none cursor-pointer"
> >
<option value="" className="bg-panel text-main"> <option value="" className="bg-panel text-main">
Text Text
@@ -573,42 +571,21 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
))} ))}
</select> </select>
) : ( ) : (
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span> <span className="font-mono text-[11px] text-faint px-1">{block.lang || 'Text'}</span>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> {/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
<button <button
type="button" type="button"
onClick={handleCopy} 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" className="absolute bottom-2 right-2 z-10 h-9 w-9 sm:h-8 sm:w-8 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-muted hover:text-main opacity-100 sm:opacity-0 sm:group-hover/code:opacity-100 focus-visible:opacity-100 transition-opacity cursor-pointer"
title="In die Zwischenablage kopieren" title={copied ? 'Kopiert!' : 'In die Zwischenablage kopieren'}
aria-label={copied ? 'Kopiert!' : 'Kopieren'}
> >
{copied ? ( {copied ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
<>
<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> </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 with Line Numbers */} {/* Live Highlighted Overlay + Textarea with Line Numbers */}
<div className="flex relative w-full overflow-hidden"> <div className="flex relative w-full overflow-hidden">
{/* Line Numbers Column */} {/* Line Numbers Column */}
@@ -618,7 +595,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
onScroll={handleLineNumbersScroll} onScroll={handleLineNumbersScroll}
onWheel={handleLineNumbersWheel} onWheel={handleLineNumbersWheel}
data-testid="code-line-numbers" data-testid="code-line-numbers"
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden" className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right shrink-0 overflow-hidden"
> >
{Array.from({ length: lineCount }, (_, i) => ( {Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} data-line-number={i + 1}> <div key={i + 1} data-line-number={i + 1}>
@@ -85,7 +85,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
}} }}
disabled={transcribing || !!error} disabled={transcribing || !!error}
aria-label="Diktat beenden" aria-label="Diktat beenden"
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none" className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-4 py-2 rounded-3xl border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
> >
{(interim || chunkBusy) && !error && ( {(interim || chunkBusy) && !error && (
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none"> <div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
@@ -1613,8 +1613,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within: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 */} {/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && ( {canEdit && ['callout', 'h1', 'h2', 'h3', 'divider', 'code', 'numbered'].includes(block.type) && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -77,19 +77,14 @@ describe('CodeBlockEditor', () => {
expect(updates[0].lang).toBe('yaml'); expect(updates[0].lang).toBe('yaml');
expect(select.value).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(); unmount();
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text" // Reader view (!canEdit): shows plain text instead of <select>
const readerView = render( const readerView = render(
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} /> <CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
); );
expect(readerView.container.querySelector('select')).toBeNull(); expect(readerView.container.querySelector('select')).toBeNull();
expect(readerView.getByText('yaml')).toBeTruthy(); 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', () => { it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
@@ -108,14 +103,14 @@ describe('CodeBlockEditor', () => {
fireEvent.click(copyBtn); fireEvent.click(copyBtn);
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"'); expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
expect(screen.getByText('Kopiert!')).toBeTruthy(); expect(screen.getByRole('button', { name: 'Kopiert!' })).toBeTruthy();
act(() => { act(() => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
}); });
expect(screen.queryByText('Kopiert!')).toBeNull(); expect(screen.queryByRole('button', { name: 'Kopiert!' })).toBeNull();
expect(screen.getByText('Kopieren')).toBeTruthy(); expect(screen.getByRole('button', { name: 'Kopieren' })).toBeTruthy();
}); });
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => { it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {