feat(ui): update instant capture, count badges, mobile triage and folder modal
This commit is contained in:
1 parent
fd72500d17
commit
1753208cba
6 files changed
+119
-15
No files matched your search
@@ -8,11 +8,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
|
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
|
||||||
|
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
|
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
|
||||||
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
|
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
|
||||||
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
|
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
|
||||||
|
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
|
||||||
|
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
|
||||||
|
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
|
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const desktopAnchor = anchor
|
const desktopAnchor = anchor
|
||||||
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
|
? ({
|
||||||
|
'--sheet-left': `${anchor.x}px`,
|
||||||
|
'--sheet-top': `${anchor.y}px`,
|
||||||
|
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
|
||||||
|
'--sheet-y': `calc(${anchor.y}px + 8px)`,
|
||||||
|
} as React.CSSProperties)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||||
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||||
anchor
|
anchor
|
||||||
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
|
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
|
||||||
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
ArrowUp,
|
ArrowDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
});
|
});
|
||||||
const isListening = voice.state === 'listening';
|
const isListening = voice.state === 'listening';
|
||||||
const isTranscribing = voice.state === 'transcribing';
|
const isTranscribing = voice.state === 'transcribing';
|
||||||
|
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
: isTranscribing
|
: isTranscribing
|
||||||
? 'Wird erkannt…'
|
? 'Wird erkannt…'
|
||||||
: hideTargetPicker || fixedTarget === 'inbox'
|
: hideTargetPicker || fixedTarget === 'inbox'
|
||||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
? 'Quick Dump erfassen…'
|
||||||
: target === 'task'
|
: target === 'task'
|
||||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Aufgabe erfassen…'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
|
||||||
: target === 'document'
|
: target === 'document'
|
||||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Notiz erfassen…'
|
||||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
: 'Quick Dump…'
|
||||||
}
|
}
|
||||||
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
|
className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
|
||||||
|
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
|
||||||
|
}`}
|
||||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
<span className="inline">
|
<span className="inline">
|
||||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
? 'Quick Dump'
|
? 'Quick Dump'
|
||||||
|
|||||||
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
{/* Mobile Quick Action Buttons under the card (Touch targets >= 44px) */}
|
||||||
|
<div className="grid grid-cols-4 gap-1.5 sm:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('discard')}
|
||||||
|
title="Wegwerfen"
|
||||||
|
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-muted flex-shrink-0" />
|
||||||
|
<span>Wegwerfen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={writableLists.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowDateModal(false);
|
||||||
|
setShowListSelector((prev) => !prev);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf eine geteilte Liste setzen'
|
||||||
|
}
|
||||||
|
className={`btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer ${
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: showListSelector
|
||||||
|
? 'border-accent text-accent bg-accent-dim'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span className="truncate max-w-full">{singleWritableList ? singleWritableList.title : 'Liste'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||||
|
title="In Notiz umwandeln"
|
||||||
|
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Notiz</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||||
|
title="Als Aufgabe übernehmen"
|
||||||
|
className="btn-primary h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Aufgabe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('discard')}
|
onClick={() => handleAction('discard')}
|
||||||
|
|||||||
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
expect(api.triageAction).not.toHaveBeenCalled();
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
expect(onDeckChange).not.toHaveBeenCalled();
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders mobile quick action buttons and triggers discard', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
|
||||||
|
fireEvent.click(mobileDiscard);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -318,12 +318,14 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.count-badge-accent {
|
.count-badge-accent {
|
||||||
background-color: var(--accent-primary);
|
background: var(--accent-dim);
|
||||||
color: var(--text-on-accent);
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
}
|
}
|
||||||
.count-badge-muted {
|
.count-badge-muted {
|
||||||
background-color: var(--text-muted);
|
background-color: var(--bg-panel-elevated);
|
||||||
color: var(--bg-panel);
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.count-badge-corner {
|
.count-badge-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in new issue
Block a user