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).
### 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.
- 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.
@@ -87,7 +87,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
return (
<div className="my-1 space-y-1">
{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)
</div>
) : src ? (
@@ -99,7 +99,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
onClick={() => void transcribe()}
disabled={transcribing}
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" />}
<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 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" />
</div>
)}
@@ -108,7 +108,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
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">
{state === 'recording' && (
@@ -162,7 +162,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
onClick={cancel}
disabled={state === 'uploading'}
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" />
</button>
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import Prism from 'prismjs';
import { Code, Copy, Check } from 'lucide-react';
import { Copy, Check } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
Prism.manual = true;
@@ -311,6 +311,7 @@ export interface CodeBlockEditorProps {
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
onChangeContent?: (content: string) => void;
onChangeLang?: (lang: string) => void;
/** Kept for callers; converting back to text lives in the bullet's action bubble now */
onConvertToText?: () => void;
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
@@ -326,7 +327,6 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
registerTextareaRef,
onChangeContent,
onChangeLang,
onConvertToText,
handleFocus,
handleBlur,
handleContentChange,
@@ -542,17 +542,15 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
{/* 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" />
<div className="group/code relative bg-code rounded-2xl my-0.5 max-w-full overflow-hidden">
{/* Language at the top left; no header bar */}
<div className="flex items-center px-1.5 pt-1 text-[11px] font-mono text-faint">
{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"
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">
Text
@@ -573,42 +571,21 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
))}
</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 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
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"
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={copied ? 'Kopiert!' : 'In die Zwischenablage kopieren'}
aria-label={copied ? 'Kopiert!' : '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>
</>
)}
{copied ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
</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 */}
<div className="flex relative w-full overflow-hidden">
{/* Line Numbers Column */}
@@ -618,7 +595,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
onScroll={handleLineNumbersScroll}
onWheel={handleLineNumbersWheel}
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) => (
<div key={i + 1} data-line-number={i + 1}>
@@ -85,7 +85,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
}}
disabled={transcribing || !!error}
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 && (
<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'
}`}
>
{/* Convert back to bullet for headings, callouts, divider */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
{/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider', 'code', 'numbered'].includes(block.type) && (
<button
type="button"
onClick={() => {
@@ -77,19 +77,14 @@ describe('CodeBlockEditor', () => {
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"
// Reader view (!canEdit): shows plain text instead of <select>
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', () => {
@@ -108,14 +103,14 @@ describe('CodeBlockEditor', () => {
fireEvent.click(copyBtn);
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
expect(screen.getByText('Kopiert!')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kopiert!' })).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1500);
});
expect(screen.queryByText('Kopiert!')).toBeNull();
expect(screen.getByText('Kopieren')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Kopiert!' })).toBeNull();
expect(screen.getByRole('button', { name: 'Kopieren' })).toBeTruthy();
});
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {