feat: select text across bullets and delete it like a text editor; Ctrl+Z in tasks; notes and lists without emoji; custom done columns complete tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2b9daba75
commit
6fb98d4002
27 files changed
+535
-64
No files matched your search
+12
-1
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.45.0] - 2026-11-01
|
||||
|
||||
### Added
|
||||
- Notizen: Text lässt sich mit der Maus über mehrere Punkte hinweg markieren. Entf/Backspace löscht ihn wie in einem Texteditor (der Rest des ersten und des letzten Punkts wird ein Punkt, Punkte dazwischen samt Unterpunkten verschwinden), ein getipptes Zeichen ersetzt ihn, Strg+C kopiert, Strg+X schneidet aus. Strg+Z holt alles in einem Schritt zurück.
|
||||
- Aufgaben: Strg+Z (Cmd+Z) macht Löschen, Verschieben (Spalten, Reihenfolge in Kanban und Liste) und Abhaken rückgängig. Nach dem Löschen erscheint unten „Aufgabe im Papierkorb · Rückgängig“, auch am Handy.
|
||||
- Notizen, Aufgabenlisten und Listen lassen sich ohne Emoji anlegen: Alle Emoji-Auswähler haben „Entfernen“. Ohne Emoji steht der Titel allein; im Kopf einer Notiz zeigt ein dezentes Symbol, wo man eins wählen kann.
|
||||
|
||||
### Fixed
|
||||
- Kanban: Eine Aufgabe, die in eine selbst angelegte Erledigt-Spalte gezogen wurde, galt nach dem Umsortieren nicht als erledigt (nur die Standard-Spalte „Erledigt“ zählte).
|
||||
|
||||
## [0.44.0] - 2026-11-01
|
||||
|
||||
### Changed
|
||||
@@ -718,7 +728,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...dev
|
||||
[0.45.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...v0.45.0
|
||||
[0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0
|
||||
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
||||
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.44.0",
|
||||
"version": "0.45.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.44.0",
|
||||
"version": "0.45.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.44.0",
|
||||
"version": "0.45.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { SmilePlus } from 'lucide-react';
|
||||
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
|
||||
|
||||
interface EmojiIconButtonProps {
|
||||
value: string;
|
||||
// "" = no icon (removed in the picker)
|
||||
onChange: (emoji: string) => void;
|
||||
// Shown while no icon is chosen
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
|
||||
// picker as the icon of an open note (Doku/ui-patterns.md).
|
||||
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange, fallback }) => {
|
||||
// picker as the icon of an open note, removing included (Doku/ui-patterns.md).
|
||||
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||
return (
|
||||
@@ -19,17 +19,16 @@ export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChang
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
|
||||
title="Icon auswählen"
|
||||
aria-label="Icon auswählen"
|
||||
title={value ? 'Icon ändern' : 'Icon wählen'}
|
||||
aria-label={value ? 'Icon ändern' : 'Icon wählen'}
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
|
||||
>
|
||||
{value || fallback}
|
||||
{value || <SmilePlus className="w-4 h-4 text-faint" strokeWidth={1.75} />}
|
||||
</button>
|
||||
{anchor && (
|
||||
<EmojiPickerModal
|
||||
currentIcon={value}
|
||||
anchor={anchor}
|
||||
allowRemove={false}
|
||||
onSelectEmoji={(emoji) => {
|
||||
onChange(emoji);
|
||||
setAnchor(null);
|
||||
|
||||
@@ -257,8 +257,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||
|
||||
const getTargetLabel = () => {
|
||||
if (target === 'task') return selectedTaskList ? `${selectedTaskList.icon || '📋'} ${selectedTaskList.title}` : 'Direkt-Aufgabe';
|
||||
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
|
||||
if (target === 'task') return selectedTaskList ? (selectedTaskList.icon ? `${selectedTaskList.icon} ${selectedTaskList.title}` : selectedTaskList.title) : 'Direkt-Aufgabe';
|
||||
if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste';
|
||||
if (target === 'document') return 'Direkt-Notiz';
|
||||
return 'Quick Dump';
|
||||
};
|
||||
@@ -536,7 +536,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{tl.icon || '📋'}</span>
|
||||
{tl.icon && <span>{tl.icon}</span>}
|
||||
<span>{tl.title}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -761,6 +761,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
|
||||
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
|
||||
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
|
||||
{ key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' },
|
||||
{ key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' },
|
||||
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
|
||||
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
|
||||
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
|
||||
|
||||
@@ -1171,7 +1171,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-semibold text-main truncate">
|
||||
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
||||
{activeList ? [activeList.icon, activeList.title].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
|
||||
</span>
|
||||
{openItems.length > 0 && (
|
||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||
@@ -1204,7 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="🛒" />
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
@@ -1540,7 +1540,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onClose={() => setShowShareModal(false)}
|
||||
title={`Liste teilen: ${activeList.title}`}
|
||||
resourceName={activeList.title}
|
||||
resourceIcon={activeList.icon || '🛒'}
|
||||
resourceIcon={activeList.icon || undefined}
|
||||
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
|
||||
currentUserId={currentUser?.id}
|
||||
shares={(activeList.shares || []).map((s) => ({
|
||||
|
||||
@@ -606,7 +606,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
{[tl.icon, tl.title].filter(Boolean).join(' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -596,8 +596,83 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
// --- TASK ACTIONS ---
|
||||
|
||||
// --- UNDO (Strg+Z / Cmd+Z): deleting, moving and ticking off, newest first; deleted tasks come back
|
||||
// from the task trash. Deleting also shows "Rückgängig" for phones.
|
||||
type UndoEntry =
|
||||
| { kind: 'delete'; ids: string[] }
|
||||
| { kind: 'order'; snapshot: { id: string; sortOrder: number; kanbanStatus?: string; isCompleted: boolean }[] }
|
||||
| { kind: 'status'; id: string; isCompleted: boolean; kanbanStatus?: string };
|
||||
const undoStackRef = useRef<UndoEntry[]>([]);
|
||||
const [toast, setToast] = useState<{ text: string; undo: boolean } | null>(null);
|
||||
const pushUndo = (entry: UndoEntry) => {
|
||||
undoStackRef.current = [...undoStackRef.current, entry].slice(-20);
|
||||
setToast(null); // its "Rückgängig" would now undo this newer step
|
||||
};
|
||||
const orderSnapshot = () =>
|
||||
tasks.map((t) => ({ id: t.id, sortOrder: t.sortOrder ?? 0, kanbanStatus: t.kanbanStatus, isCompleted: !!t.isCompleted }));
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
const showToast = (text: string, undo = false) => {
|
||||
setToast({ text, undo });
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = window.setTimeout(() => setToast(null), undo ? 8000 : 3000);
|
||||
};
|
||||
useEffect(() => () => {
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const undoLast = async () => {
|
||||
const entry = undoStackRef.current.pop();
|
||||
if (!entry) return;
|
||||
setToast(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
if (entry.kind === 'delete') {
|
||||
for (const id of entry.ids) await api.restoreTrashedTask(id).catch(() => {});
|
||||
showToast(entry.ids.length === 1 ? 'Aufgabe wiederhergestellt' : `${entry.ids.length} Aufgaben wiederhergestellt`);
|
||||
} else if (entry.kind === 'order') {
|
||||
const byId = new Map(entry.snapshot.map((x) => [x.id, x]));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
const old = byId.get(t.id);
|
||||
return old ? { ...t, sortOrder: old.sortOrder, kanbanStatus: old.kanbanStatus, isCompleted: old.isCompleted } : t;
|
||||
})
|
||||
);
|
||||
await api.reorderTasks(
|
||||
entry.snapshot.map((x) => ({ id: x.id, sortOrder: x.sortOrder, kanbanStatus: x.kanbanStatus })),
|
||||
selectedListId !== 'all' ? selectedListId : undefined
|
||||
);
|
||||
showToast('Verschieben rückgängig gemacht');
|
||||
} else {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === entry.id ? { ...t, isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus } : t))
|
||||
);
|
||||
await api.updateThought(entry.id, { isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus });
|
||||
showToast('Rückgängig gemacht');
|
||||
}
|
||||
} finally {
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
}
|
||||
};
|
||||
const undoLastRef = useRef(undoLast);
|
||||
undoLastRef.current = undoLast;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'z') return;
|
||||
// Text fields keep their own undo
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
|
||||
if (undoStackRef.current.length === 0) return;
|
||||
e.preventDefault();
|
||||
void undoLastRef.current();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const handleToggleDone = async (task: Thought) => {
|
||||
triggerHaptic(15);
|
||||
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
|
||||
const nextState = !task.isCompleted;
|
||||
const cols = columnsFor(task);
|
||||
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
|
||||
@@ -615,6 +690,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
||||
triggerHaptic(15);
|
||||
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
|
||||
const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
|
||||
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
||||
|
||||
@@ -784,6 +860,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const handleDeletePermanent = async (id: string) => {
|
||||
triggerHaptic(20);
|
||||
await api.deleteThought(id);
|
||||
pushUndo({ kind: 'delete', ids: [id] });
|
||||
showToast('Aufgabe im Papierkorb', true);
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
@@ -803,7 +881,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleClearDone = async () => {
|
||||
triggerHaptic(20);
|
||||
// The ones that will go (completed and editable here), for undo
|
||||
const ids = doneTasks.filter((t) => !isTaskReadOnly(t)).map((t) => t.id);
|
||||
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
|
||||
if (ids.length > 0) {
|
||||
pushUndo({ kind: 'delete', ids });
|
||||
showToast(ids.length === 1 ? 'Aufgabe im Papierkorb' : `${ids.length} Aufgaben im Papierkorb`, true);
|
||||
}
|
||||
await loadData();
|
||||
onRefreshStats();
|
||||
};
|
||||
@@ -967,6 +1051,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
sortOrder: idx,
|
||||
}));
|
||||
|
||||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||
setTasks(updatedWithSort);
|
||||
setSortBy('custom');
|
||||
|
||||
@@ -1103,6 +1188,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
sortOrder: idx,
|
||||
}));
|
||||
|
||||
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
|
||||
setTasks(updatedWithSort);
|
||||
setSortBy('custom');
|
||||
|
||||
@@ -1333,7 +1419,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
},
|
||||
...safeTaskLists.map((tl) => ({
|
||||
id: `list-${tl.id}`,
|
||||
label: `${tl.icon || '📋'} ${tl.title}`,
|
||||
label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title,
|
||||
badge: tl.tasksCount || undefined,
|
||||
active: selectedListId === tl.id,
|
||||
onClick: () => setSelectedListId(tl.id),
|
||||
@@ -1846,7 +1932,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-semibold text-main truncate">
|
||||
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
|
||||
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
|
||||
</span>
|
||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||
{viewMode === 'kanban'
|
||||
@@ -2101,7 +2187,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<option value="">Standard (Alle Aufgaben)</option>
|
||||
{safeTaskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon || '📋'} {tl.title}
|
||||
{tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -2341,7 +2427,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" />
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
@@ -2356,6 +2442,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Short notice after deleting or undoing (same look as in the workspace) */}
|
||||
{toast && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
|
||||
>
|
||||
<span>{toast.text}</span>
|
||||
{toast.undo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void undoLast()}
|
||||
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
|
||||
title="Rückgängig (Strg+Z)"
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UNIFIED SHARE LIST MODAL */}
|
||||
{showShareListModal && sharingTaskList && (
|
||||
<UnifiedShareModal
|
||||
@@ -2366,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}}
|
||||
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
||||
resourceName={sharingTaskList.title}
|
||||
resourceIcon={sharingTaskList.icon || '📋'}
|
||||
resourceIcon={sharingTaskList.icon || undefined}
|
||||
isOwner={sharingTaskList.isOwner !== false}
|
||||
currentUserId={currentUser?.id}
|
||||
shares={(sharingTaskList.shares || []).map((sh) => ({
|
||||
@@ -2817,7 +2923,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<p className="text-[11px] text-faint truncate">
|
||||
{selectedListId === 'all' && (
|
||||
<>
|
||||
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'}
|
||||
{listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
|
||||
onClick={() => onOpen(l.docId)}
|
||||
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||
>
|
||||
<span className="shrink-0">{l.icon || '🗒️'}</span>
|
||||
{l.icon && <span className="shrink-0">{l.icon}</span>}
|
||||
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||
{l.title || 'Unbenannte Notiz'}
|
||||
</span>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Redo2,
|
||||
MoreHorizontal,
|
||||
CalendarDays,
|
||||
SmilePlus,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
@@ -708,6 +709,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
// A selection over several bullets was deleted (OutlinerTree): one change, so Strg+Z undoes it at once
|
||||
const handleReplaceRange = useCallback(
|
||||
(first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => {
|
||||
if (!canEdit) return;
|
||||
const changed = new Map([first, ...movedChildren].map((b) => [b.id, b]));
|
||||
const gone = new Set(deleteIds);
|
||||
applyLocalBlocks((prev) =>
|
||||
ensureNonEmptyBlocks(docId, prev.filter((b) => !gone.has(b.id)).map((b) => changed.get(b.id) || b))
|
||||
);
|
||||
},
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
|
||||
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
|
||||
const handleMoveSibling = useCallback(
|
||||
@@ -1254,6 +1268,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onReplaceRange={handleReplaceRange}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onMoveSibling={handleMoveSibling}
|
||||
onOpenTask={onOpenTask}
|
||||
@@ -1286,12 +1301,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}`}
|
||||
title={inJournal ? undefined : 'Icon ändern'}
|
||||
>
|
||||
{/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji */}
|
||||
{/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji;
|
||||
a note without an icon shows a faint "choose icon" to editors only */}
|
||||
{inJournal ? (
|
||||
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
||||
) : (
|
||||
doc?.icon || '🗒️'
|
||||
)}
|
||||
) : doc?.icon ? (
|
||||
doc.icon
|
||||
) : canEdit ? (
|
||||
<SmilePlus className="w-6 h-6 sm:w-7 sm:h-7 text-faint" strokeWidth={1.5} aria-label="Icon wählen" />
|
||||
) : null}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<EmojiPickerModal
|
||||
@@ -1352,6 +1370,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onInsertBlocks={handleInsertBlocks}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onReplaceRange={handleReplaceRange}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onMoveSibling={handleMoveSibling}
|
||||
onOpenTask={onOpenTask}
|
||||
|
||||
@@ -29,8 +29,6 @@ interface EmojiPickerModalProps {
|
||||
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
||||
// opens below this button on desktop
|
||||
anchor?: DOMRect | null;
|
||||
// Offer "Icon entfernen" (notes; a create dialog only has its default icon to go back to)
|
||||
allowRemove?: boolean;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
@@ -38,7 +36,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
anchor,
|
||||
allowRemove = true,
|
||||
}) => {
|
||||
const { inset, visibleHeight } = useKeyboardInset();
|
||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
@@ -151,7 +148,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{currentIcon && allowRemove && (
|
||||
{currentIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji('')}
|
||||
@@ -205,8 +202,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */}
|
||||
{currentIcon && allowRemove && (
|
||||
{/* Desktop: removing the icon (none at all) as a quiet last row, like an entry of a context menu */}
|
||||
{currentIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji('')}
|
||||
|
||||
@@ -362,7 +362,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="text-base shrink-0">{d.icon || '🗒️'}</span>
|
||||
{d.icon && <span className="text-base shrink-0">{d.icon}</span>}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||
{d.title || 'Unbenannte Notiz'}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
|
||||
onClick={() => setTarget(d.id)}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
||||
>
|
||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
||||
</button>
|
||||
|
||||
@@ -125,6 +125,8 @@ export interface OutlinerBlockProps {
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
/** Part of a selection over several bullets (browsers without the CSS Highlight API tint the row) */
|
||||
inRangeSelection?: boolean;
|
||||
}
|
||||
|
||||
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
||||
@@ -175,6 +177,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
inRangeSelection,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||
@@ -1077,7 +1080,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-outline-block={block.id}
|
||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
||||
inRangeSelection ? 'bg-accent/15' : ''
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'mt-2.5 sm:mt-3'
|
||||
: block.type === 'h2'
|
||||
@@ -1387,7 +1393,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||
<span ref={renderedTextRef} data-outline-text className={activeFocus ? 'invisible' : ''}>
|
||||
{previewUrl && !activeFocus ? (
|
||||
// The card above shows the page; the address itself becomes a small link marker
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
|
||||
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||
import { numberedLabels } from '../../lib/outlineList';
|
||||
import { flushSync } from 'react-dom';
|
||||
@@ -39,6 +39,9 @@ export interface OutlinerTreeProps {
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
|
||||
* bullets to delete, and children of the last bullet that move under the first */
|
||||
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
@@ -67,6 +70,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onReplaceRange,
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
|
||||
@@ -665,8 +669,245 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
// --- Selection over several bullets ---
|
||||
// Each bullet is a text field of its own, so the browser cannot select across them. Dragging from one
|
||||
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
|
||||
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
|
||||
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
|
||||
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies.
|
||||
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
|
||||
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
|
||||
const rangeSelRef = useRef<RangeSel | null>(null);
|
||||
rangeSelRef.current = rangeSel;
|
||||
const treeRootRef = useRef<HTMLDivElement>(null);
|
||||
const dragAnchorRef = useRef<{ id: string; offset: number } | null>(null);
|
||||
const highlightSupported = typeof CSS !== 'undefined' && 'highlights' in CSS && typeof (window as any).Highlight === 'function';
|
||||
|
||||
const caretAtPoint = (x: number, y: number): { node: Node; offset: number } | null => {
|
||||
const doc = document as any;
|
||||
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||
const p = doc.caretPositionFromPoint(x, y);
|
||||
return p ? { node: p.offsetNode, offset: p.offset } : null;
|
||||
}
|
||||
if (typeof doc.caretRangeFromPoint === 'function') {
|
||||
const r = doc.caretRangeFromPoint(x, y);
|
||||
return r ? { node: r.startContainer, offset: r.startOffset } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Position in the raw text of a bullet for a point in its rendered text (outside it: start or end)
|
||||
const rawOffsetAtPoint = (row: HTMLElement, id: string, x: number, y: number) => {
|
||||
const raw = textareaMapRef.current.get(id)?.value ?? blocks.find((b) => b.id === id)?.content ?? '';
|
||||
const span = row.querySelector<HTMLElement>('[data-outline-text]');
|
||||
const caret = caretAtPoint(x, y);
|
||||
if (span && caret && span.contains(caret.node)) {
|
||||
const range = document.createRange();
|
||||
range.setStart(span, 0);
|
||||
range.setEnd(caret.node, caret.offset);
|
||||
return { offset: mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length), node: caret.node, nodeOffset: caret.offset };
|
||||
}
|
||||
const rect = (span || row).getBoundingClientRect();
|
||||
const before = y < rect.top || (y <= rect.bottom && x < rect.left);
|
||||
return { offset: before ? 0 : raw.length };
|
||||
};
|
||||
|
||||
const clearRangeSel = useCallback(() => {
|
||||
dragAnchorRef.current = null;
|
||||
setRangeSel(null);
|
||||
}, []);
|
||||
|
||||
// Highlight the selected text of the bullets other than the start one
|
||||
useEffect(() => {
|
||||
if (!highlightSupported) return;
|
||||
const registry = (CSS as any).highlights;
|
||||
if (!rangeSel || !treeRootRef.current) {
|
||||
registry.delete('outline-range');
|
||||
return;
|
||||
}
|
||||
const order = visibleEntries.map((e) => e.block.id);
|
||||
const a = order.indexOf(rangeSel.anchorId);
|
||||
const f = order.indexOf(rangeSel.focusId);
|
||||
if (a < 0 || f < 0) {
|
||||
registry.delete('outline-range');
|
||||
return;
|
||||
}
|
||||
const ranges: Range[] = [];
|
||||
const spanOf = (id: string) =>
|
||||
treeRootRef.current!.querySelector<HTMLElement>(`[data-outline-block="${id}"] [data-outline-text]`);
|
||||
for (let i = Math.min(a, f); i <= Math.max(a, f); i++) {
|
||||
const id = order[i];
|
||||
if (id === rangeSel.anchorId) continue; // shown by the browser in its text field
|
||||
const span = spanOf(id);
|
||||
if (!span) continue;
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(span);
|
||||
if (id === rangeSel.focusId && rangeSel.focusNode && span.contains(rangeSel.focusNode)) {
|
||||
if (f > a) r.setEnd(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||
else r.setStart(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||
}
|
||||
ranges.push(r);
|
||||
}
|
||||
registry.set('outline-range', new (window as any).Highlight(...ranges));
|
||||
return () => registry.delete('outline-range');
|
||||
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||
|
||||
// Mouse: a drag that leaves its bullet becomes a selection over several bullets
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const start = dragAnchorRef.current;
|
||||
if (!start || !(e.buttons & 1)) return;
|
||||
const row = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-outline-block]');
|
||||
if (!row || !treeRootRef.current?.contains(row)) return;
|
||||
const id = row.dataset.outlineBlock!;
|
||||
if (id === start.id) {
|
||||
if (rangeSelRef.current) setRangeSel(null);
|
||||
return;
|
||||
}
|
||||
const at = rawOffsetAtPoint(row, id, e.clientX, e.clientY);
|
||||
setRangeSel({ anchorId: start.id, anchorOffset: start.offset, focusId: id, focusOffset: at.offset, focusNode: at.node, focusNodeOffset: at.nodeOffset });
|
||||
};
|
||||
const onUp = () => {
|
||||
dragAnchorRef.current = null;
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
// rawOffsetAtPoint only reads refs and blocks
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blocks]);
|
||||
|
||||
const handleTreeMouseDownCapture = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
setRangeSel(null);
|
||||
const target = e.target as HTMLElement;
|
||||
const row = target.closest<HTMLElement>('[data-outline-block]');
|
||||
if (!row || target.closest('button, a, input, select, [contenteditable="true"]')) {
|
||||
dragAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
const id = row.dataset.outlineBlock!;
|
||||
// The bullet's own handler places the caret first; read it right after
|
||||
dragAnchorRef.current = { id, offset: rawOffsetAtPoint(row, id, e.clientX, e.clientY).offset };
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaMapRef.current.get(id);
|
||||
if (dragAnchorRef.current?.id === id && ta && document.activeElement === ta) {
|
||||
dragAnchorRef.current = { id, offset: ta.selectionStart };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// The selection in text order: first bullet + offset, last bullet + offset, bullets in between
|
||||
const resolveRangeSel = (sel: RangeSel) => {
|
||||
const order = visibleEntries.map((en) => en.block.id);
|
||||
const a = order.indexOf(sel.anchorId);
|
||||
const f = order.indexOf(sel.focusId);
|
||||
if (a < 0 || f < 0 || a === f) return null;
|
||||
const [firstId, firstOffset, lastId, lastOffset] =
|
||||
a < f ? [sel.anchorId, sel.anchorOffset, sel.focusId, sel.focusOffset] : [sel.focusId, sel.focusOffset, sel.anchorId, sel.anchorOffset];
|
||||
const fIdx = blocks.findIndex((b) => b.id === firstId);
|
||||
const lIdx = blocks.findIndex((b) => b.id === lastId);
|
||||
if (fIdx < 0 || lIdx < 0 || lIdx <= fIdx) return null;
|
||||
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
|
||||
};
|
||||
|
||||
const selectedText = (sel: RangeSel) => {
|
||||
const r = resolveRangeSel(sel);
|
||||
if (!r) return '';
|
||||
const lines = [(r.first.content || '').slice(r.firstOffset)];
|
||||
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || '');
|
||||
lines.push((r.last.content || '').slice(0, r.lastOffset));
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const deleteRangeSel = (sel: RangeSel, insert = '') => {
|
||||
const r = resolveRangeSel(sel);
|
||||
if (!r || !canEdit) return false;
|
||||
const notMergeable = ['code', 'divider', 'table', 'progress'];
|
||||
if (notMergeable.includes(r.first.type) || notMergeable.includes(r.last.type)) return false;
|
||||
const deleted = blocks.slice(r.fIdx + 1, r.lIdx + 1);
|
||||
const holder = [r.first, ...deleted].map((b) => lockHolder(b.id)).find(Boolean);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return true;
|
||||
}
|
||||
// Children of the last bullet stay and move under the first, like merging two bullets
|
||||
const lastLevel = Math.max(0, r.last.level || 0);
|
||||
const delta = Math.max(0, r.first.level || 0) - lastLevel;
|
||||
const moved: DocumentBlock[] = [];
|
||||
for (let j = r.lIdx + 1; j < blocks.length; j++) {
|
||||
const lvl = Math.max(0, blocks[j].level || 0);
|
||||
if (lvl <= lastLevel) break;
|
||||
moved.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
|
||||
}
|
||||
const merged: DocumentBlock = {
|
||||
...r.first,
|
||||
content: (r.first.content || '').slice(0, r.firstOffset) + insert + (r.last.content || '').slice(r.lastOffset),
|
||||
};
|
||||
const deleteIds = deleted.map((b) => b.id);
|
||||
if (onReplaceRange) {
|
||||
onReplaceRange(merged, deleteIds, moved);
|
||||
} else {
|
||||
if (onUpdateBlocks) onUpdateBlocks([merged, ...moved]);
|
||||
else [merged, ...moved].forEach((b) => onUpdateBlock(b, true));
|
||||
deleteIds.forEach((id) => onDeleteBlock(id));
|
||||
}
|
||||
clearRangeSel();
|
||||
focusBlockInput(r.first.id, r.firstOffset + insert.length);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Keys while a selection over several bullets exists
|
||||
const deleteRangeSelRef = useRef(deleteRangeSel);
|
||||
deleteRangeSelRef.current = deleteRangeSel;
|
||||
const selectedTextRef = useRef(selectedText);
|
||||
selectedTextRef.current = selectedText;
|
||||
useEffect(() => {
|
||||
if (!rangeSel) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const sel = rangeSelRef.current;
|
||||
if (!sel) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const stop = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
if (mod && (e.key === 'c' || e.key === 'x')) {
|
||||
stop();
|
||||
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {});
|
||||
if (e.key === 'x') deleteRangeSelRef.current(sel);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
stop();
|
||||
deleteRangeSelRef.current(sel);
|
||||
return;
|
||||
}
|
||||
if (!mod && !e.altKey && e.key.length === 1) {
|
||||
stop();
|
||||
deleteRangeSelRef.current(sel, e.key);
|
||||
return;
|
||||
}
|
||||
if (!['Shift', 'Control', 'Meta', 'Alt'].includes(e.key)) clearRangeSel();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [rangeSel, clearRangeSel]);
|
||||
|
||||
const rangeRowIds = useMemo(() => {
|
||||
if (!rangeSel || highlightSupported) return null;
|
||||
const order = visibleEntries.map((e) => e.block.id);
|
||||
const a = order.indexOf(rangeSel.anchorId);
|
||||
const f = order.indexOf(rangeSel.focusId);
|
||||
if (a < 0 || f < 0) return null;
|
||||
return new Set(order.slice(Math.min(a, f), Math.max(a, f) + 1));
|
||||
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||
const isLast = entryIdx === visibleEntries.length - 1;
|
||||
return (
|
||||
@@ -709,6 +950,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
inRangeSelection={rangeRowIds?.has(block.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
title="Schreibgeschützt ansehen"
|
||||
>
|
||||
<span className="text-lg shrink-0">{t.icon || '🗒️'}</span>
|
||||
{t.icon && <span className="text-lg shrink-0">{t.icon}</span>}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||
{t.title || 'Unbenannte Notiz'}
|
||||
|
||||
@@ -282,7 +282,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -589,7 +589,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
try {
|
||||
const created = await api.createDocument({
|
||||
title: title.trim() || 'Neue Notiz',
|
||||
icon: icon.trim() || '🗒️',
|
||||
icon: icon.trim(),
|
||||
folderPath,
|
||||
blocks: [
|
||||
{
|
||||
@@ -1420,7 +1420,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
map.set(d.id, {
|
||||
docId: d.id,
|
||||
title: d.title || 'Unbenannte Notiz',
|
||||
icon: d.icon || '🗒️',
|
||||
icon: d.icon || '',
|
||||
folderPath: d.folderPath || '',
|
||||
matchedBlocks: matched,
|
||||
});
|
||||
@@ -1433,7 +1433,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
map.set(dg.docId, {
|
||||
docId: dg.docId,
|
||||
title: dg.title || 'Unbenannte Notiz',
|
||||
icon: dg.icon || '🗒️',
|
||||
icon: dg.icon || '',
|
||||
folderPath: dg.folderPath || '',
|
||||
matchedBlocks: dg.matchedBlocks || [],
|
||||
});
|
||||
@@ -1806,7 +1806,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
||||
title="Andere Notiz wählen"
|
||||
>
|
||||
{selectedDocObj?.icon} {selectedDocObj?.title}
|
||||
{[selectedDocObj?.icon, selectedDocObj?.title].filter(Boolean).join(' ')}
|
||||
</button>
|
||||
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||
<button
|
||||
@@ -1990,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||
@@ -2061,7 +2061,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||
@@ -2363,7 +2363,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
|
||||
title={meta.docs[0].title}
|
||||
>
|
||||
<span>{meta.docs[0].icon || '🗒️'}</span>
|
||||
{meta.docs[0].icon && <span>{meta.docs[0].icon}</span>}
|
||||
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2464,7 +2464,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
||||
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
<span>{docGroup.icon || '🗒️'}</span>
|
||||
{docGroup.icon && <span>{docGroup.icon}</span>}
|
||||
<span>{docGroup.title}</span>
|
||||
{docGroup.folderPath && (
|
||||
<span className="badge-neutral text-[10px] font-normal">
|
||||
@@ -2714,7 +2714,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
||||
title={item.doc.title}
|
||||
>
|
||||
<span>{item.doc.icon || '🗒️'}</span>
|
||||
{item.doc.icon && <span>{item.doc.icon}</span>}
|
||||
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
||||
{item.doc.folderPath && (
|
||||
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
||||
@@ -2815,7 +2815,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-accent" />
|
||||
)}
|
||||
<span>{doc.icon || '🗒️'}</span>
|
||||
{doc.icon && <span>{doc.icon}</span>}
|
||||
<span>{doc.title}</span>
|
||||
<span className="badge-neutral text-[9px] font-mono">
|
||||
{docOpenCount} offen
|
||||
@@ -2967,7 +2967,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="🗒️" />
|
||||
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
||||
@@ -606,6 +606,11 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection over several bullets in a note (OutlinerTree, CSS Highlight API) */
|
||||
::highlight(outline-range) {
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||
fieldset:disabled :is(button, input, select, textarea) {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -953,11 +953,12 @@ export const api = {
|
||||
|
||||
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
let title = titleOrSpaceId;
|
||||
let icon = titleOrIcon || '🛒';
|
||||
// An empty icon means "no icon"; only a missing one gets the default
|
||||
let icon = titleOrIcon ?? '🛒';
|
||||
let type = (iconOrType as any) || 'shopping';
|
||||
if (listType !== undefined) {
|
||||
title = titleOrIcon || '';
|
||||
icon = iconOrType || '🛒';
|
||||
icon = iconOrType ?? '🛒';
|
||||
type = listType;
|
||||
}
|
||||
const l = await request<SharedList>('/api/v1/lists', {
|
||||
|
||||
@@ -47,7 +47,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||
FolderPath string `json:"folderPath"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
@@ -58,7 +58,12 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, derefIcon(req.Icon), req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||||
if err == nil && doc != nil && req.Icon != nil && *req.Icon == "" {
|
||||
if err = s.store.ClearIcon("documents", doc.ID); err == nil {
|
||||
doc.Icon = ""
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -330,3 +335,11 @@ func (s *Server) docTitle(userID, docID string) string {
|
||||
}
|
||||
return "Unbenannte Notiz"
|
||||
}
|
||||
|
||||
// derefIcon: the icon of a create request; a missing one lets the store pick the default.
|
||||
func derefIcon(icon *string) string {
|
||||
if icon == nil {
|
||||
return ""
|
||||
}
|
||||
return *icon
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||
ListType string `json:"listType"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -47,7 +47,12 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
|
||||
lst, err := s.store.CreateList(userID, req.Title, derefIcon(req.Icon), req.ListType)
|
||||
if err == nil && lst != nil && req.Icon != nil && *req.Icon == "" {
|
||||
if err = s.store.ClearIcon("lists", lst.ID); err == nil {
|
||||
lst.Icon = ""
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -28,14 +28,19 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
SpaceID string `json:"spaceId"`
|
||||
TeamID string `json:"teamId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Icon *string `json:"icon"` // "" = no icon; missing = the default
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
|
||||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, derefIcon(req.Icon), req.Color)
|
||||
if err == nil && tl != nil && req.Icon != nil && *req.Icon == "" {
|
||||
if err = s.store.ClearIcon("task_lists", tl.ID); err == nil {
|
||||
tl.Icon = ""
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -958,3 +958,15 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// ClearIcon removes the icon of a note, task list or list (created on purpose without one; the create
|
||||
// functions put in a default for an empty icon).
|
||||
func (s *Store) ClearIcon(table, id string) error {
|
||||
switch table {
|
||||
case "documents", "task_lists", "lists":
|
||||
default:
|
||||
return fmt.Errorf("unknown table %q", table)
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE `+table+` SET icon = '' WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func inTaskTrash(s *Store, id string) bool {
|
||||
@@ -215,3 +217,31 @@ func TestPurgeExpiredAreas(t *testing.T) {
|
||||
t.Fatal("things from before the update count from the update on")
|
||||
}
|
||||
}
|
||||
|
||||
// Moving a card into a list's own done column (any ID) completes the task, other columns reopen it.
|
||||
func TestReorderIntoCustomDoneColumn(t *testing.T) {
|
||||
s, err := NewStore(filepath.Join(t.TempDir(), "donecol.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "")
|
||||
cols := []domain.KanbanColumn{{ID: "todo", Name: "Offen"}, {ID: "col_fertig", Name: "Fertig", IsDone: true}}
|
||||
if _, err := s.UpdateTaskList(anna.ID, list.ID, nil, nil, nil, nil, &cols); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.CreateThoughtWithDetails(anna.ID, "Bericht", "normal", "manual", "", list.ID, "todo", "")
|
||||
_, _ = s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", list.ID)
|
||||
done, open := "col_fertig", "todo"
|
||||
if err := s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &done}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetThoughtByIDUnchecked(task.ID); !got.IsCompleted {
|
||||
t.Fatal("the list's done column should complete the task")
|
||||
}
|
||||
_ = s.ReorderTasks(list.ID, []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 0, KanbanStatus: &open}})
|
||||
if got, _ := s.GetThoughtByIDUnchecked(task.ID); got.IsCompleted {
|
||||
t.Fatal("an open column reopens it")
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
||||
for _, it := range items {
|
||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||
isCompleted := 0
|
||||
if *it.KanbanStatus == "done" {
|
||||
if isDoneColumn(tx, it.ID, *it.KanbanStatus) {
|
||||
isCompleted = 1
|
||||
var was int
|
||||
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
||||
@@ -275,6 +275,24 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDoneColumn: whether a Kanban column completes the task: the column of the task's list marked as done
|
||||
// (its ID may be anything), else the default ID "done".
|
||||
func isDoneColumn(q interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}, taskID, column string) bool {
|
||||
var raw string
|
||||
_ = q.QueryRow(`SELECT COALESCE(tl.columns, '') FROM thoughts t LEFT JOIN task_lists tl ON tl.id = t.task_list_id WHERE t.id = ?`, taskID).Scan(&raw)
|
||||
var cols []domain.KanbanColumn
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &cols) == nil && len(cols) > 0 {
|
||||
for _, c := range cols {
|
||||
if c.ID == column {
|
||||
return c.IsDone
|
||||
}
|
||||
}
|
||||
}
|
||||
return column == "done"
|
||||
}
|
||||
|
||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
|
||||
Reference in new issue
Block a user