Merge dev into main (v0.48.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s
This commit is contained in:
commit
654a86fee0
9 files changed
+255
-169
No files matched your search
+7
-1
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.48.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace, Hashtag-Seite: Die Fundstellen lassen sich direkt bearbeiten. Jede Notiz erscheint als feste Überschrift (Klick öffnet sie), darunter der normale Outliner mit den Punkten, die den Hashtag tragen, samt Unterpunkten. Tippen, Enter, Einrücken, Abhaken und Rückgängig wirken direkt in der Originalnotiz, auch gemeinsam mit anderen. Wer den Hashtag aus einem Punkt entfernt oder per Enter einen neuen Punkt ohne Hashtag anlegt, sieht ihn, bis der Cursor ihn verlässt. Die Rahmen um die einzelnen Notizen sind weg, die Seite sieht aus wie eine normale Notiz; Notizen laden erst beim Hineinscrollen.
|
||||||
|
|
||||||
## [0.47.0] - 2026-11-01
|
## [0.47.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -750,7 +755,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.
|
- 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.
|
- 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.47.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.48.0...dev
|
||||||
|
[0.48.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.47.0...v0.48.0
|
||||||
[0.47.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...v0.47.0
|
[0.47.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...v0.47.0
|
||||||
[0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0
|
[0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0
|
||||||
[0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2
|
[0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). Ordner zeigen ihren Zustand am offenen bzw. geschlossenen Ordnersymbol (kein Pfeil).
|
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). Ordner zeigen ihren Zustand am offenen bzw. geschlossenen Ordnersymbol (kein Pfeil).
|
||||||
- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge (eine angeheftete Notiz steht zweimal im Baum; der Bereich beginnt an der Stelle, die angeklickt wurde); ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht).
|
- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge (eine angeheftete Notiz steht zweimal im Baum; der Bereich beginnt an der Stelle, die angeklickt wurde); ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht).
|
||||||
- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“.
|
- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“.
|
||||||
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
|
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite trägt eine eigene Freitext-Notiz (`tag_pages`) und zeigt darunter jede Notiz mit Fundstellen als feste Überschrift und darunter den echten Outliner der Punkte mit dem Hashtag samt Unterpunkten (`DocumentEditor` mit `variant="tag"`, je Notiz eine eigene Sync-Verbindung, erst beim Hineinscrollen geladen). Bearbeiten wirkt direkt in der Notiz; ein Punkt ohne Hashtag (entfernt oder neu per Enter) bleibt sichtbar, solange der Cursor darin steht, und verschwindet, sobald er ihn verlässt. Die Reihenfolge der Notizen bleibt, solange die Seite offen ist.
|
||||||
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
||||||
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-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.
|
**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",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface LazyMountProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
// Shown until the place scrolls into view (keeps the page's height roughly right)
|
||||||
|
placeholder: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders its children only once they come near the visible area, and keeps them afterwards. For lists
|
||||||
|
// of heavier parts, e.g. one live note editor per entry on a hashtag page.
|
||||||
|
export const LazyMount: React.FC<LazyMountProps> = ({ children, placeholder }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (shown || !el) return;
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
setShown(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) setShown(true);
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [shown]);
|
||||||
|
return <div ref={ref}>{shown ? children : placeholder}</div>;
|
||||||
|
};
|
||||||
@@ -67,8 +67,11 @@ export interface DocumentEditorProps {
|
|||||||
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
||||||
onDocCreated?: (doc: Document) => void;
|
onDocCreated?: (doc: Document) => void;
|
||||||
onDocRemoved?: (docId: string) => void;
|
onDocRemoved?: (docId: string) => void;
|
||||||
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
|
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title.
|
||||||
variant?: 'page' | 'journal';
|
* 'tag': inside a hashtag page – the title as a fixed heading, below only the branches with tagFilter */
|
||||||
|
variant?: 'page' | 'journal' | 'tag';
|
||||||
|
/** variant 'tag': the hashtag (without #) whose bullets are shown */
|
||||||
|
tagFilter?: string;
|
||||||
/** Large screens: the action buttons render into this element (the workspace tab bar) */
|
/** Large screens: the action buttons render into this element (the workspace tab bar) */
|
||||||
actionsTarget?: HTMLElement | null;
|
actionsTarget?: HTMLElement | null;
|
||||||
/** Distraction-free mode: reading width, menu entry to leave it */
|
/** Distraction-free mode: reading width, menu entry to leave it */
|
||||||
@@ -238,13 +241,17 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDocCreated,
|
onDocCreated,
|
||||||
onDocRemoved,
|
onDocRemoved,
|
||||||
variant = 'page',
|
variant = 'page',
|
||||||
|
tagFilter,
|
||||||
actionsTarget,
|
actionsTarget,
|
||||||
focusMode = false,
|
focusMode = false,
|
||||||
onToggleFocusMode,
|
onToggleFocusMode,
|
||||||
}) => {
|
}) => {
|
||||||
const inJournal = variant === 'journal';
|
const inJournal = variant === 'journal';
|
||||||
|
const inTagPage = variant === 'tag';
|
||||||
|
// Embedded in a list of notes (journal, hashtag page): no own scrolling, action bar or backlinks
|
||||||
|
const embedded = inJournal || inTagPage;
|
||||||
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
|
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
|
||||||
const desktopBar = !inJournal && large && Boolean(actionsTarget);
|
const desktopBar = !embedded && large && Boolean(actionsTarget);
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||||
@@ -278,7 +285,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = scrollRef.current;
|
const root = scrollRef.current;
|
||||||
const el = titleInputRef.current;
|
const el = titleInputRef.current;
|
||||||
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
|
if (embedded || !root || !el || typeof IntersectionObserver === 'undefined') {
|
||||||
setTitleScrolledAway(false);
|
setTitleScrolledAway(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,7 +296,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [zoomedBlockId, doc?.id, inJournal]);
|
}, [zoomedBlockId, doc?.id, embedded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
@@ -789,6 +796,99 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Hashtag page (variant 'tag') ---
|
||||||
|
// Shown are the bullets carrying the hashtag with all their children. The bullet with the cursor (and
|
||||||
|
// the bullets above it) and bullets just created here stay even without the hashtag, so typing never
|
||||||
|
// pulls the text away; once the cursor leaves, a bullet without the hashtag disappears.
|
||||||
|
const [tagFocusId, setTagFocusId] = useState<string | null>(null);
|
||||||
|
const [tagFreshIds, setTagFreshIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const tagBlurTimerRef = useRef<number | null>(null);
|
||||||
|
const handleTagFocusChange = useCallback((id: string | null) => {
|
||||||
|
engineRef.current?.focusBlock(id);
|
||||||
|
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
|
||||||
|
tagBlurTimerRef.current = null;
|
||||||
|
if (id === null) {
|
||||||
|
// Moving from one bullet to the next blurs first; only a real leave counts
|
||||||
|
tagBlurTimerRef.current = window.setTimeout(() => {
|
||||||
|
setTagFocusId(null);
|
||||||
|
setTagFreshIds((prev) => (prev.size === 0 ? prev : new Set()));
|
||||||
|
}, 150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTagFocusId(id);
|
||||||
|
setTagFreshIds((prev) => (prev.size === 0 || prev.has(id) ? prev : new Set()));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
// What was shown last time: of the bullets above the cursor only those stay that were already shown
|
||||||
|
// (adding a heading above would start the branch anew and lose the cursor)
|
||||||
|
const tagShownRef = useRef<Set<string>>(new Set());
|
||||||
|
const tagGroups = useMemo(() => {
|
||||||
|
if (!inTagPage || !tagFilter) return [] as DocumentBlock[][];
|
||||||
|
const tag = tagFilter.replace(/^#/, '').toLowerCase();
|
||||||
|
const keep = new Set<string>(tagFreshIds);
|
||||||
|
const fIdx = tagFocusId ? blocks.findIndex((b) => b.id === tagFocusId) : -1;
|
||||||
|
if (fIdx >= 0) {
|
||||||
|
keep.add(blocks[fIdx].id);
|
||||||
|
let lvl = Math.max(0, blocks[fIdx].level || 0);
|
||||||
|
for (let i = fIdx - 1; i >= 0 && lvl > 0; i--) {
|
||||||
|
const l = Math.max(0, blocks[i].level || 0);
|
||||||
|
if (l < lvl) {
|
||||||
|
if (!tagShownRef.current.has(blocks[i].id)) break;
|
||||||
|
keep.add(blocks[i].id);
|
||||||
|
lvl = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Runs of shown bullets: a shown bullet brings its children; gaps start a new run
|
||||||
|
const groups: DocumentBlock[][] = [];
|
||||||
|
let current: DocumentBlock[] | null = null;
|
||||||
|
let insideLevel: number | null = null;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const lvl = Math.max(0, b.level || 0);
|
||||||
|
if (insideLevel !== null && lvl <= insideLevel) insideLevel = null;
|
||||||
|
const shown = insideLevel !== null || keep.has(b.id) || extractHashtags(b.content).includes(tag);
|
||||||
|
if (!shown) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
current = [];
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.push(b);
|
||||||
|
if (insideLevel === null) insideLevel = lvl;
|
||||||
|
}
|
||||||
|
tagShownRef.current = new Set(groups.flat().map((b) => b.id));
|
||||||
|
return groups;
|
||||||
|
}, [inTagPage, tagFilter, blocks, tagFocusId, tagFreshIds]);
|
||||||
|
const handleTagCreateBlock = useCallback(
|
||||||
|
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||||
|
setTagFreshIds((prev) => new Set(prev).add(newBlock.id));
|
||||||
|
handleCreateBlock(newBlock, afterBlockId);
|
||||||
|
},
|
||||||
|
[handleCreateBlock]
|
||||||
|
);
|
||||||
|
const handleTagInsertBlocks = useCallback(
|
||||||
|
(afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||||
|
setTagFreshIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
newBlocks.forEach((b) => next.add(b.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
handleInsertBlocks(afterBlockId, updatedCurrent, newBlocks);
|
||||||
|
},
|
||||||
|
[handleInsertBlocks]
|
||||||
|
);
|
||||||
|
// Dragging inside one run: its positions are positions in the whole note
|
||||||
|
const tagMoveBlock = (group: DocumentBlock[]) => (blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
|
const anchor = targetIndex < group.length ? group[targetIndex] : group[group.length - 1];
|
||||||
|
const idx = currentBlocksRef.current.findIndex((b) => b.id === anchor.id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
handleMoveBlock(blockId, targetIndex < group.length ? idx : idx + 1, newLevel);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
const handleTogglePin = async () => {
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
try {
|
try {
|
||||||
@@ -1065,13 +1165,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
|
className={embedded ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
|
||||||
data-sync-channel={engineState?.channel}
|
data-sync-channel={engineState?.channel}
|
||||||
>
|
>
|
||||||
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
|
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
|
||||||
|
|
||||||
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
|
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
|
||||||
{!inJournal && !desktopBar && (
|
{!embedded && !desktopBar && (
|
||||||
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
|
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{onBackMobile && (
|
{onBackMobile && (
|
||||||
@@ -1173,7 +1273,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
|
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
|
||||||
<div
|
<div
|
||||||
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
|
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
|
||||||
inJournal ? 'py-3' : 'py-5'
|
inTagPage ? 'py-2' : inJournal ? 'py-3' : 'py-5'
|
||||||
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
|
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
|
||||||
>
|
>
|
||||||
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
|
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
|
||||||
@@ -1289,6 +1389,54 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
STANDARD GESAMTDOKUMENT-ANSICHT
|
STANDARD GESAMTDOKUMENT-ANSICHT
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
inTagPage ? (
|
||||||
|
/* Hashtag page: the note's title as a fixed heading, below the branches with the hashtag,
|
||||||
|
edited right in this note */
|
||||||
|
tagGroups.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenDocBlock?.(docId)}
|
||||||
|
className="flex items-center gap-2 min-w-0 max-w-full text-left text-lg font-semibold text-main hover:text-accent transition-colors cursor-pointer"
|
||||||
|
title="Notiz öffnen"
|
||||||
|
>
|
||||||
|
{doc?.icon && <span className="shrink-0">{doc.icon}</span>}
|
||||||
|
<span className="truncate">{title || 'Unbenannte Notiz'}</span>
|
||||||
|
{doc?.folderPath && <span className="shrink-0 text-[11px] font-normal text-faint">{doc.folderPath}</span>}
|
||||||
|
</button>
|
||||||
|
{tagGroups.map((group) => (
|
||||||
|
<OutlinerTree
|
||||||
|
key={group[0].id}
|
||||||
|
compact
|
||||||
|
trailingArea={false}
|
||||||
|
docId={docId}
|
||||||
|
blocks={group}
|
||||||
|
canEdit={canEdit}
|
||||||
|
levelOffset={Math.min(...group.map((b) => Math.max(0, b.level || 0)))}
|
||||||
|
onZoomBlock={(id) => onOpenDocBlock?.(docId, id)}
|
||||||
|
onUpdateBlock={handleUpdateBlock}
|
||||||
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
|
onCreateBlock={handleTagCreateBlock}
|
||||||
|
onInsertBlocks={handleTagInsertBlocks}
|
||||||
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
|
onMoveBlock={tagMoveBlock(group)}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onTagClick={onSelectTag}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
|
onFocusedBlockChange={handleTagFocusChange}
|
||||||
|
lockedBlocks={lockedBlocks}
|
||||||
|
onLockedAction={handleLockedAction}
|
||||||
|
onResolveConflict={handleResolveConflict}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onRedo={handleRedo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Document Icon + Title Header */}
|
{/* Document Icon + Title Header */}
|
||||||
<div className="flex items-center gap-3 mb-4 relative">
|
<div className="flex items-center gap-3 mb-4 relative">
|
||||||
@@ -1386,8 +1534,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
{onOpenDocBlock && !inTrash && !embedded && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface OutlinerTreeProps {
|
|||||||
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
|
/** 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 */
|
* bullets to delete, and children of the last bullet that move under the first */
|
||||||
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
|
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
|
||||||
|
/** The empty area below the last bullet (click adds one); off where several trees stack (hashtag page) */
|
||||||
|
trailingArea?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
@@ -71,6 +73,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onReplaceRange,
|
onReplaceRange,
|
||||||
|
trailingArea = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -955,7 +958,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && trailingArea && (
|
||||||
<div
|
<div
|
||||||
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
|
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
|
||||||
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession
|
|||||||
import { MergeDialog } from './MergeDialog';
|
import { MergeDialog } from './MergeDialog';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
|
import { LazyMount } from '../../components/LazyMount';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
@@ -1438,13 +1439,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}, [docs, selectedTag, tagDetails]);
|
}, [docs, selectedTag, tagDetails]);
|
||||||
|
|
||||||
const activeTagTotalMatches = useMemo(() => {
|
// The hashtag page keeps its order while open: a note whose last hashtag was just removed stays in its
|
||||||
let count = 0;
|
// place (its editor then shows nothing), new matches come at the end; leaving the page starts afresh
|
||||||
for (const g of activeTagGroups) {
|
const tagPageOrderRef = useRef<{ tag: string | null; groups: typeof activeTagGroups }>({ tag: null, groups: [] });
|
||||||
count += g.matchedBlocks.length;
|
const tagPageGroups = useMemo(() => {
|
||||||
|
const prev = tagPageOrderRef.current;
|
||||||
|
const byId = new Map(activeTagGroups.map((g) => [g.docId, g]));
|
||||||
|
let groups = activeTagGroups;
|
||||||
|
if (prev.tag === selectedTag) {
|
||||||
|
const known = new Set(prev.groups.map((g) => g.docId));
|
||||||
|
groups = [
|
||||||
|
...prev.groups.map((g) => byId.get(g.docId) || g),
|
||||||
|
...activeTagGroups.filter((g) => !known.has(g.docId)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return count;
|
tagPageOrderRef.current = { tag: selectedTag, groups };
|
||||||
}, [activeTagGroups]);
|
return groups;
|
||||||
|
}, [activeTagGroups, selectedTag]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (subView !== 'tag_detail') tagPageOrderRef.current = { tag: null, groups: [] };
|
||||||
|
}, [subView]);
|
||||||
|
|
||||||
|
|
||||||
const selectedDocObj = useMemo(
|
const selectedDocObj = useMemo(
|
||||||
() => docs.find((d) => d.id === selectedDocId) || null,
|
() => docs.find((d) => d.id === selectedDocId) || null,
|
||||||
@@ -2386,7 +2401,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : subView === 'tag_detail' && selectedTag ? (
|
) : subView === 'tag_detail' && selectedTag ? (
|
||||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-editor">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{/* Back to all tags */}
|
{/* Back to all tags */}
|
||||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
@@ -2435,162 +2450,44 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matched Document Blocks */}
|
{/* Matched bullets: each note as a fixed heading with the live outliner of its branches below,
|
||||||
<div className="space-y-3">
|
edited right in the note (DocumentEditor variant "tag"); editors load when scrolled near */}
|
||||||
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
|
{tagPageGroups.length === 0 ? (
|
||||||
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
|
<p className="py-8 text-center text-xs text-muted">Keine Punkte mit #{selectedTag} gefunden.</p>
|
||||||
</h3>
|
|
||||||
{activeTagGroups.length === 0 ? (
|
|
||||||
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
|
|
||||||
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
activeTagGroups.map((docGroup) => {
|
<div className="space-y-2 -mx-2 sm:-mx-8">
|
||||||
const docObj = docs.find((d) => d.id === docGroup.docId);
|
{tagPageGroups.map((docGroup) => (
|
||||||
return (
|
<LazyMount
|
||||||
<div
|
|
||||||
key={docGroup.docId}
|
key={docGroup.docId}
|
||||||
className="p-3 rounded-card border border-border bg-canvas space-y-2"
|
placeholder={
|
||||||
>
|
<div className="px-2 sm:px-8 py-2 min-h-[6rem] text-lg font-semibold text-muted">
|
||||||
<div className="flex items-center justify-between">
|
{docGroup.icon && <span className="mr-2">{docGroup.icon}</span>}
|
||||||
<button
|
{docGroup.title}
|
||||||
type="button"
|
|
||||||
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
|
||||||
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
{docGroup.icon && <span>{docGroup.icon}</span>}
|
|
||||||
<span>{docGroup.title}</span>
|
|
||||||
{docGroup.folderPath && (
|
|
||||||
<span className="badge-neutral text-[10px] font-normal">
|
|
||||||
📁 {docGroup.folderPath}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
|
||||||
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
|
|
||||||
title="Notiz öffnen"
|
|
||||||
>
|
|
||||||
<span>Notiz öffnen</span>
|
|
||||||
<ExternalLink className="w-2.5 h-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 pl-3 border-l-2 border-border/70">
|
|
||||||
{docGroup.matchedBlocks.map((mb) => (
|
|
||||||
<div key={mb.rootBlock.id} className="space-y-1">
|
|
||||||
{/* Root Bullet Point containing the hashtag */}
|
|
||||||
<div
|
|
||||||
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
|
|
||||||
className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
|
|
||||||
title="Klick: Zu dieser Zeile springen und markieren"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
|
||||||
{mb.rootBlock.type === 'todo' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (docObj) {
|
|
||||||
handleToggleRollupTodo(
|
|
||||||
docObj,
|
|
||||||
mb.rootBlock.id,
|
|
||||||
Boolean(mb.rootBlock.checked)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
|
|
||||||
mb.rootBlock.checked
|
|
||||||
? 'bg-status-ok border-status-ok text-on-accent'
|
|
||||||
: 'border-strong hover:border-accent text-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
<DocumentEditor
|
||||||
</button>
|
key={`${docGroup.docId}:${selectedTag}`}
|
||||||
) : (
|
variant="tag"
|
||||||
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
|
tagFilter={selectedTag}
|
||||||
•
|
docId={docGroup.docId}
|
||||||
</span>
|
currentUser={currentUser}
|
||||||
)}
|
initialDoc={docs.find((d) => d.id === docGroup.docId) || null}
|
||||||
<span
|
availableFolders={allFolderPaths}
|
||||||
className={`flex-1 break-words ${
|
onDocMetadataChange={handleDocMetadataChange}
|
||||||
mb.rootBlock.checked ? 'line-through text-faint' : ''
|
onDeleteDoc={handleDeleteDocument}
|
||||||
}`}
|
onOpenShareModal={openDocShareModal}
|
||||||
>
|
onSelectTag={handleSelectTag}
|
||||||
{mb.rootBlock.content}
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
</span>
|
onOpenDocBlock={handleJumpToDocBlock}
|
||||||
</div>
|
onOpenTask={onOpenTask}
|
||||||
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
|
/>
|
||||||
</div>
|
</LazyMount>
|
||||||
|
|
||||||
{/* All Children indented */}
|
|
||||||
{mb.children && mb.children.length > 0 && (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{mb.children.map((ch) => {
|
|
||||||
const relLevel = Math.max(
|
|
||||||
1,
|
|
||||||
(ch.level || 0) - (mb.rootBlock.level || 0)
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={ch.id}
|
|
||||||
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
|
|
||||||
style={{ paddingLeft: `${relLevel * 16}px` }}
|
|
||||||
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
|
|
||||||
title="Klick: Zu dieser Zeile springen und markieren"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
|
||||||
{ch.type === 'todo' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (docObj) {
|
|
||||||
handleToggleRollupTodo(
|
|
||||||
docObj,
|
|
||||||
ch.id,
|
|
||||||
Boolean(ch.checked)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
|
|
||||||
ch.checked
|
|
||||||
? 'bg-status-ok border-status-ok text-on-accent'
|
|
||||||
: 'border-strong hover:border-accent text-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
|
|
||||||
◦
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`flex-1 break-words ${
|
|
||||||
ch.checked ? 'line-through text-faint' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ch.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : subView === 'todos' ? (
|
) : subView === 'todos' ? (
|
||||||
/* Each nesting level lays a light tint of the text colour on top: darker per level in
|
/* Each nesting level lays a light tint of the text colour on top: darker per level in
|
||||||
light themes, lighter per level in dark ones */
|
light themes, lighter per level in dark ones */
|
||||||
|
|||||||
Reference in new issue
Block a user