feat(workspace): borderless code blocks, round code/audio/dictation blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
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:
1 parent
e243e328c7
commit
546bcba666
7 files changed
+57
-83
No files matched your search
@@ -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,73 +542,50 @@ 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" />
|
||||
{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"
|
||||
<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-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"
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
<option value="" className="bg-panel text-main">
|
||||
Text
|
||||
</option>
|
||||
{hasCustomUnknownLang && (
|
||||
<option value={selectValue} className="bg-panel text-main">
|
||||
{selectValue}
|
||||
</option>
|
||||
)}
|
||||
</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>
|
||||
{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-faint px-1">{block.lang || 'Text'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
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.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* 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', () => {
|
||||
|
||||
Reference in new issue
Block a user