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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 16:27:43 +01:00
1 parent e2b9daba75
commit 6fb98d4002
27 files changed
+535 -64

No files matched your search

+7 -8
View File
@@ -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>
);
+2
View File
@@ -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>
+112 -6
View File
@@ -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
+5
View File
@@ -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;
+3 -2
View File
@@ -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', {