feat(mobile/nav): add preview for current note

This commit is contained in:
Elian Doran
2026-04-20 23:38:10 +03:00
parent 9e681a7ec7
commit e2bce67d28
2 changed files with 165 additions and 103 deletions

View File

@@ -6,20 +6,15 @@
contain: content;
}
.mobile-navigator-header {
.mobile-navigator-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
padding: 4px 6px;
border-bottom: 1px solid var(--main-border-color);
background: var(--main-background-color);
position: sticky;
top: 0;
z-index: 1;
}
.mobile-navigator-back {
flex: 0 0 auto;
font-size: 1.6em;
min-width: 40px;
min-height: 40px;
@@ -29,41 +24,7 @@
visibility: hidden;
}
.mobile-navigator-title {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--main-text-color);
padding: 6px 8px;
min-height: 40px;
font-size: 1.05em;
font-weight: 600;
text-align: start;
min-width: 0;
cursor: pointer;
}
.mobile-navigator-title:disabled {
cursor: default;
opacity: 0.7;
}
.mobile-navigator-title-icon {
font-size: 1.1em;
flex: 0 0 auto;
}
.mobile-navigator-title-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-navigator-list {
.mobile-navigator-scroll {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
@@ -73,6 +34,98 @@
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-navigator-current-tile {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--accented-background-color, var(--hover-item-background-color));
border-bottom: 2px solid var(--main-border-color);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
color: var(--main-text-color);
}
.mobile-navigator-current-tile:active {
filter: brightness(0.95);
}
.mobile-navigator-current-tile.is-active {
background: var(--active-item-background-color);
color: var(--active-item-text-color);
}
.mobile-navigator-current-tile.is-archived {
opacity: 0.7;
}
.mobile-navigator-current-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
}
.mobile-navigator-current-icon {
flex: 0 0 auto;
font-size: 1.3em;
}
.mobile-navigator-current-title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.15em;
font-weight: 600;
}
.mobile-navigator-current-open {
flex: 0 0 auto;
font-size: 1.1em;
opacity: 0.6;
}
.mobile-navigator-current-preview {
position: relative;
max-height: 140px;
overflow: hidden;
font-size: 0.92em;
line-height: 1.35;
color: var(--muted-text-color, var(--main-text-color));
pointer-events: none;
}
.mobile-navigator-current-preview::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, transparent, var(--accented-background-color, var(--hover-item-background-color)));
}
.mobile-navigator-current-preview:empty,
.mobile-navigator-current-preview .note-book-content:empty {
display: none;
}
.mobile-navigator-current-preview .note-book-content {
max-width: 100%;
}
.mobile-navigator-current-preview img,
.mobile-navigator-current-preview video {
max-width: 100%;
height: auto;
}
.mobile-navigator-list {
display: flex;
flex-direction: column;
}
.mobile-navigator-row {
display: flex;
align-items: center;
@@ -115,5 +168,5 @@
.mobile-navigator-row-chevron {
flex: 0 0 auto;
font-size: 1.4em;
opacity: 0.6;
opacity: 0.5;
}

View File

@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
import type FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import { NoteContent } from "../collections/legacy/ListOrGridView";
import ActionButton from "../react/ActionButton";
import {
useActiveNoteContext,
@@ -19,8 +20,9 @@ import NoItems from "../react/NoItems";
/**
* A touch-native replacement for the Fancytree-based note tree on mobile. Shows one
* "column" of children at a time (iOS Files / macOS Finder style), with a back
* chevron to pop up the hierarchy and tappable rows to drill in or open notes.
* "column" of children at a time (iOS Files / macOS Finder style): the column's
* current note is rendered at the top with a content preview and opens on tap,
* while tapping child rows drills deeper without navigating.
*/
export default function MobileNoteNavigator() {
const { notePath: activeNotePath, hoistedNoteId, noteContext, parentComponent } = useActiveNoteContext();
@@ -42,7 +44,6 @@ export default function MobileNoteNavigator() {
const parentPath = stack[stack.length - 1];
const parentNoteId = getLastSegment(parentPath);
if (loadResults.getBranchRows().some((b) => b.parentNoteId === parentNoteId)) {
// No state change, but force a re-render by resetting the same stack reference.
setStack((prev) => [...prev]);
}
});
@@ -50,6 +51,7 @@ export default function MobileNoteNavigator() {
const currentParentPath = stack[stack.length - 1];
const currentParentId = getLastSegment(currentParentPath);
const parentNote = useNote(currentParentId);
const parentIcon = useNoteIcon(parentNote);
const activeNoteId = activeNotePath ? getLastSegment(activeNotePath) : undefined;
// Froca's initial `tree` response only returns the top of the tree; deeper levels are
@@ -82,34 +84,23 @@ export default function MobileNoteNavigator() {
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
}, []);
const openNote = useCallback(
async (childNotePath: string, closeSidebar: boolean) => {
await noteContext?.setNote(childNotePath);
if (closeSidebar) {
manualStackRef.current = false;
parentComponent?.triggerCommand("setActiveScreen", { screen: "detail" });
}
},
[noteContext, parentComponent]
);
const drillInto = useCallback(
async (childNotePath: string) => {
manualStackRef.current = true;
setStack((prev) => [...prev, childNotePath]);
await noteContext?.setNote(childNotePath);
},
[noteContext]
);
const openParent = useCallback(() => {
const openCurrent = useCallback(async () => {
if (!currentParentPath) return;
openNote(currentParentPath, true);
}, [currentParentPath, openNote]);
await noteContext?.setNote(currentParentPath);
manualStackRef.current = false;
parentComponent?.triggerCommand("setActiveScreen", { screen: "detail" });
}, [currentParentPath, noteContext, parentComponent]);
const drillInto = useCallback((childNotePath: string) => {
manualStackRef.current = true;
setStack((prev) => [...prev, childNotePath]);
}, []);
const isCurrentActive = !!activeNoteId && activeNoteId === currentParentId;
return (
<div className="mobile-note-navigator">
<div className="mobile-navigator-header">
<div className="mobile-navigator-toolbar">
<ActionButton
className={clsx("mobile-navigator-back", !canGoBack && "invisible")}
icon="bx bx-chevron-left"
@@ -117,37 +108,56 @@ export default function MobileNoteNavigator() {
onClick={canGoBack ? goBack : undefined}
disabled={!canGoBack}
/>
<button
type="button"
className="mobile-navigator-title"
onClick={openParent}
disabled={!parentNote}
>
<Icon icon={parentNote?.getIcon() ?? "bx bx-folder"} className="mobile-navigator-title-icon" />
<span className="mobile-navigator-title-text">
{parentNote?.title ?? t("mobile_note_navigator.loading")}
</span>
</button>
</div>
<div className="mobile-navigator-list">
{!isLoaded ? (
<NoItems icon="bx bx-loader" text={t("mobile_note_navigator.loading")} />
) : children.length === 0 ? (
<NoItems icon="bx bx-folder-open" text={t("mobile_note_navigator.empty")} />
) : (
children.map((child) => (
<NavigatorRow
key={child.branchId}
note={child.note}
prefix={child.prefix}
childNotePath={`${currentParentPath}/${child.note.noteId}`}
isActive={child.note.noteId === activeNoteId}
onOpen={openNote}
onDrill={drillInto}
/>
))
)}
<div className="mobile-navigator-scroll">
<div
className={clsx("mobile-navigator-current-tile", {
"is-active": isCurrentActive,
"is-archived": parentNote?.isArchived
})}
role="button"
tabIndex={0}
onClick={openCurrent}
>
<div className="mobile-navigator-current-header">
<Icon icon={parentIcon ?? "bx bx-folder"} className="mobile-navigator-current-icon" />
<span className="mobile-navigator-current-title">
{parentNote?.title ?? t("mobile_note_navigator.loading")}
</span>
<Icon icon="bx bx-link-external" className="mobile-navigator-current-open" />
</div>
{parentNote && (
<div className="mobile-navigator-current-preview">
<NoteContent
note={parentNote}
trim
noChildrenList
highlightedTokens={null}
includeArchivedNotes={!hideArchived}
/>
</div>
)}
</div>
<div className="mobile-navigator-list">
{!isLoaded ? (
<NoItems icon="bx bx-loader" text={t("mobile_note_navigator.loading")} />
) : children.length === 0 ? (
<NoItems icon="bx bx-folder-open" text={t("mobile_note_navigator.empty")} />
) : (
children.map((child) => (
<NavigatorRow
key={child.branchId}
note={child.note}
prefix={child.prefix}
childNotePath={`${currentParentPath}/${child.note.noteId}`}
isActive={child.note.noteId === activeNoteId}
onDrill={drillInto}
/>
))
)}
</div>
</div>
</div>
);
@@ -158,11 +168,10 @@ interface NavigatorRowProps {
prefix?: string;
childNotePath: string;
isActive: boolean;
onOpen: (notePath: string, closeSidebar: boolean) => void;
onDrill: (notePath: string) => void;
}
function NavigatorRow({ note, prefix, childNotePath, isActive, onOpen, onDrill }: NavigatorRowProps) {
function NavigatorRow({ note, prefix, childNotePath, isActive, onDrill }: NavigatorRowProps) {
const icon = useNoteIcon(note);
const hasChildren = note.hasChildren();
const colorClass = note.getColorClass();
@@ -177,11 +186,11 @@ function NavigatorRow({ note, prefix, childNotePath, isActive, onOpen, onDrill }
})}
role="button"
tabIndex={0}
onClick={() => (hasChildren ? onDrill(childNotePath) : onOpen(childNotePath, true))}
onClick={() => onDrill(childNotePath)}
>
<Icon icon={icon ?? "bx bx-note"} className="mobile-navigator-row-icon" />
<span className="mobile-navigator-row-title">{title}</span>
{hasChildren && <Icon icon="bx bx-chevron-right" className="mobile-navigator-row-chevron" />}
<Icon icon="bx bx-chevron-right" className="mobile-navigator-row-chevron" />
</div>
);
}