A filed note stays filed, and the handles are asked for
Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bb86bc5ac
commit
b2a5f051fe
12 files changed
+406
-291
No files matched your search
+91
-35
@@ -13,7 +13,6 @@ import { I } from "./ui/icons.jsx";
|
||||
import { makeTheme, FONT_UI } from "./ui/theme";
|
||||
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
||||
import { NameDialog } from "./ui/NameDialog.jsx";
|
||||
import { MoveNoteDialog } from "./ui/MoveNoteDialog.jsx";
|
||||
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
||||
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||
@@ -68,6 +67,13 @@ export default function App() {
|
||||
notebook itself. A chapter is a folder inside a notebook, so this is a
|
||||
second step of "where the user is", not a second notebook. */
|
||||
const [activeChapter, setActiveChapter] = useState(null);
|
||||
/* Whether that chapter is the place being worked in.
|
||||
*
|
||||
* Open beside or below, a chapter does not replace the notebook's list — both
|
||||
* are on screen at once, and "which of the two am I in" is then a real
|
||||
* question. It used to be answered silently by "a chapter is open", so a new
|
||||
* note went into the chapter however far away the user had since clicked. */
|
||||
const [inChapter, setInChapter] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const [showNewNb, setShowNewNb] = useState(false);
|
||||
@@ -91,8 +97,6 @@ export default function App() {
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
// { title, value, onSave } while a chapter is being named or renamed.
|
||||
const [namingChapter, setNamingChapter] = useState(null);
|
||||
// The note whose destination is being chosen, if any.
|
||||
const [movingNote, setMovingNote] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
||||
@@ -114,13 +118,6 @@ export default function App() {
|
||||
const { notes, notebooks, noteContents, backlinks } = data;
|
||||
const search = useSearchIndex(user, notes, searchQuery);
|
||||
const dnd = useNotebookDnd(data.reorderNotebooks);
|
||||
/* Notes reorder through their own list, which is the only place that knows
|
||||
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
||||
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
|
||||
const rearrangeNotes = useCallback(
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
||||
[data, activeFolder, activeChapter]
|
||||
);
|
||||
|
||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||
|
||||
@@ -169,6 +166,7 @@ export default function App() {
|
||||
(id) => {
|
||||
setActiveFolder(id);
|
||||
setActiveChapter(null);
|
||||
setInChapter(false);
|
||||
setActiveNote(null);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
@@ -214,6 +212,33 @@ export default function App() {
|
||||
}));
|
||||
}, [data.chapters, activeFolder, notes]);
|
||||
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
|
||||
/* Which list the middle column is *showing*.
|
||||
*
|
||||
* Only the fallback replaces it: beside or below, the notebook's own list
|
||||
* carries on and the chapter is a second thing on screen. Told apart from
|
||||
* `activeChapter` after a separator drawn in a notebook vanished the moment a
|
||||
* chapter was opened — the list still showed the notebook, but everything
|
||||
* around it had switched to the chapter, so the notebook's own separators
|
||||
* were filtered out and a drag in that list wrote nothing at all. */
|
||||
const listChapter = inlineChapters || columnChapters ? null : activeChapter;
|
||||
const listIsChapter = !!listChapter;
|
||||
/* Where a new note is made. In the fallback the list is the chapter, so there
|
||||
is nothing to choose; beside or below it follows the click. */
|
||||
const targetChapter = listChapter ?? (inChapter ? activeChapter : null);
|
||||
/* Both places are on screen at once, and only then is it worth saying which
|
||||
one is meant. With no chapter open there is nothing to tell apart. */
|
||||
const showsWhere = !!openChapter && !listIsChapter;
|
||||
|
||||
/* Notes reorder through their own list, which is the only place that knows
|
||||
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
||||
hook: they are dragged with a mouse, in the sidebar and on the dashboard.
|
||||
Scoped to the list on screen, not to the chapter that happens to be open
|
||||
beside it — otherwise a drag among the notebook's own rows is measured
|
||||
against the chapter's notes, finds nothing, and does nothing. */
|
||||
const rearrangeNotes = useCallback(
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
|
||||
[data, activeFolder, listChapter]
|
||||
);
|
||||
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||
const isTrashView = activeFolder === "trash";
|
||||
|
||||
@@ -243,18 +268,18 @@ export default function App() {
|
||||
/* Only the ones drawn in the list being looked at — the notebook itself, or
|
||||
the chapter that is open. */
|
||||
const separators = useMemo(
|
||||
() => separatorsFor(allSeparators, activeChapter),
|
||||
[allSeparators, activeChapter]
|
||||
() => separatorsFor(allSeparators, listChapter),
|
||||
[allSeparators, listChapter]
|
||||
);
|
||||
const writeSeparators = (next) =>
|
||||
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
|
||||
/* Written back whole: the notebook stores one list, and the ones belonging to
|
||||
the other chapters have to survive a change made in this one. */
|
||||
const writeVisible = (visible) =>
|
||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
|
||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
|
||||
|
||||
const addSeparator = () =>
|
||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
|
||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
|
||||
const renameSeparator = (id, label) =>
|
||||
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
||||
@@ -293,8 +318,7 @@ export default function App() {
|
||||
if (activeFolder === "trash") list = trashedNotes;
|
||||
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
||||
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
||||
else if (activeChapter && !inlineChapters && !columnChapters)
|
||||
list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
||||
else if (listChapter) list = activeNotes.filter((n) => n.chapterId === listChapter);
|
||||
/* In the notebook itself, a note filed into a chapter is *in* that chapter
|
||||
and not loose here — otherwise it would appear twice and moving it would
|
||||
look like it had done nothing. */
|
||||
@@ -314,9 +338,7 @@ export default function App() {
|
||||
return sortNotes(list, sortMode);
|
||||
}, [
|
||||
activeFolder,
|
||||
activeChapter,
|
||||
inlineChapters,
|
||||
columnChapters,
|
||||
listChapter,
|
||||
searchQuery,
|
||||
search.results,
|
||||
activeNotes,
|
||||
@@ -468,7 +490,7 @@ export default function App() {
|
||||
/* A note made while a chapter is open belongs to that chapter — but only
|
||||
if it is a chapter of the notebook being written into, which it is not
|
||||
when the notebook had to be asked for. */
|
||||
const chapterId = folderId === activeFolder ? activeChapter : null;
|
||||
const chapterId = folderId === activeFolder ? targetChapter : null;
|
||||
const note = await data.createNote(folderId, chapterId);
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
openNote(note.id);
|
||||
@@ -605,6 +627,19 @@ export default function App() {
|
||||
* Folders inside a notebook. Named on creation, because a chapter with no name
|
||||
* is a row nobody can tell from the next one — unlike a separator, which is a
|
||||
* line and reads perfectly well unnamed. */
|
||||
/**
|
||||
* Opens a chapter, or closes the one already open.
|
||||
*
|
||||
* Opening it also makes it the place being worked in, which is what pressing
|
||||
* a chapter means; clicking back into the notebook's list undoes that without
|
||||
* closing the chapter, so both stay readable side by side.
|
||||
*/
|
||||
const openChapterView = (id) => {
|
||||
const next = activeChapter === id ? null : id;
|
||||
setActiveChapter(next);
|
||||
setInChapter(!!next);
|
||||
};
|
||||
|
||||
const addChapter = () => {
|
||||
if (!activeNotebook) return;
|
||||
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
||||
@@ -721,8 +756,24 @@ export default function App() {
|
||||
const renderChapterNotes = (chapter) => (
|
||||
<div
|
||||
data-chapter-notes={chapter.id}
|
||||
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
|
||||
data-chapter-current={inChapter ? "on" : "off"}
|
||||
/* Pressing inside the unfolded chapter says "I am in here" — and must not
|
||||
then travel on to the list around it, which says the opposite. */
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setInChapter(true);
|
||||
}}
|
||||
style={{
|
||||
margin: "2px 0 8px 22px",
|
||||
borderLeft: `2px solid ${inChapter ? chapter.color || T.accent : `${chapter.color || T.accent}55`}`,
|
||||
paddingLeft: 8,
|
||||
}}
|
||||
>
|
||||
{inChapter && (
|
||||
<div style={{ fontSize: 10, color: T.accent, fontWeight: 700, padding: "2px 10px 4px" }}>
|
||||
· new notes land here
|
||||
</div>
|
||||
)}
|
||||
{chapterNotes.length === 0 && (
|
||||
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
||||
)}
|
||||
@@ -866,14 +917,19 @@ export default function App() {
|
||||
/* Only in the fallback: there the list becomes the chapter, and
|
||||
its header is the way back. Beside or below, the notebook's own
|
||||
list carries on and keeps its chapter rows. */
|
||||
openChapter={inlineChapters || columnChapters ? null : openChapter}
|
||||
onOpenChapter={setActiveChapter}
|
||||
openChapter={listIsChapter ? openChapter : null}
|
||||
onOpenChapter={openChapterView}
|
||||
onAddChapter={activeNotebook ? addChapter : null}
|
||||
onChapterContextMenu={chapterContextMenu}
|
||||
expandedChapter={inlineChapters ? activeChapter : null}
|
||||
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
||||
onMoveNote={setMovingNote}
|
||||
onReorder={rearrangeNotes}
|
||||
listKey={`${activeFolder}:${listChapter ?? ""}`}
|
||||
isCurrent={showsWhere ? !inChapter : null}
|
||||
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
|
||||
newNotePlace={
|
||||
targetChapter ? folderChapters.find((c) => c.id === targetChapter)?.name : folderName
|
||||
}
|
||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSelectNote={openNote}
|
||||
onNoteContextMenu={noteContextMenu}
|
||||
@@ -889,6 +945,8 @@ export default function App() {
|
||||
{columnChapters && openChapter && (
|
||||
<div
|
||||
data-chapter-column
|
||||
data-chapter-current={inChapter ? "on" : "off"}
|
||||
onPointerDown={() => setInChapter(true)}
|
||||
style={{
|
||||
width: 268,
|
||||
flexShrink: 0,
|
||||
@@ -896,6 +954,8 @@ export default function App() {
|
||||
flexDirection: "column",
|
||||
borderRight: `1px solid ${T.borderLight}`,
|
||||
background: T.panel || "transparent",
|
||||
// The same line the note list wears when it is the one in hand.
|
||||
boxShadow: inChapter ? `inset 0 2px 0 ${T.accent}` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -924,8 +984,14 @@ export default function App() {
|
||||
>
|
||||
{openChapter.name}
|
||||
</span>
|
||||
{inChapter && (
|
||||
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveChapter(null)}
|
||||
onClick={() => {
|
||||
setActiveChapter(null);
|
||||
setInChapter(false);
|
||||
}}
|
||||
title="Close chapter"
|
||||
aria-label="Close chapter"
|
||||
data-chapter-close
|
||||
@@ -1103,16 +1169,6 @@ export default function App() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{movingNote && (
|
||||
<MoveNoteDialog
|
||||
note={movingNote}
|
||||
notebooks={notebooks}
|
||||
chapters={data.chapters}
|
||||
onMove={(id, folderId, chapterId) => data.moveNote(id, folderId, chapterId)}
|
||||
onClose={() => setMovingNote(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{namingChapter && (
|
||||
<NameDialog
|
||||
title={namingChapter.title}
|
||||
|
||||
@@ -42,9 +42,11 @@ const DropLine = ({ T }) => (
|
||||
*
|
||||
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
|
||||
* row has to keep scrolling the list, and holding it has to keep opening the
|
||||
* menu. So touch gets its own target, wide enough to hit and only there while a
|
||||
* hand-made arrangement is in effect — which is the only time moving a row means
|
||||
* anything.
|
||||
* menu. So touch gets its own target, wide enough to hit.
|
||||
*
|
||||
* Only there while rearranging is switched on. Standing in every row all the
|
||||
* time it was six dots of clutter beside every title, and a row could be pulled
|
||||
* out of place by a press that was meant to open it.
|
||||
*/
|
||||
const Grip = ({ T, ...rest }) => (
|
||||
<div
|
||||
@@ -68,36 +70,6 @@ const Grip = ({ T, ...rest }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Moving a note, in one press.
|
||||
*
|
||||
* It used to be a right-click into a submenu — two steps before the list of
|
||||
* places appeared at all, and on a phone a long press followed by a flyout.
|
||||
* Sitting on the row beside the grip, it says what it does and takes one tap.
|
||||
*/
|
||||
const MoveButton = ({ T, onClick }) => (
|
||||
<button
|
||||
data-move-note
|
||||
onClick={onClick}
|
||||
title="Move to another notebook or chapter"
|
||||
aria-label="Move note"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
flexShrink: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: T.faint,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{I.move}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** Width the separator's label keeps in both states, so renaming moves nothing. */
|
||||
const MIDDLE_W = 132;
|
||||
|
||||
@@ -135,7 +107,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
/* Room above it, so it reads as the end of what came before rather than
|
||||
as decoration attached to the next note. */
|
||||
padding: "14px 8px 8px",
|
||||
cursor: "grab",
|
||||
cursor: grip ? "grab" : "default",
|
||||
}}
|
||||
>
|
||||
{grip}
|
||||
@@ -343,7 +315,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
|
||||
* putting something new in here — and they had grown into three buttons side by
|
||||
* side, two of which were icons you had to hover to identify.
|
||||
*/
|
||||
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef(null);
|
||||
|
||||
@@ -388,7 +360,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
<div style={{ position: "relative" }} ref={box}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="New Note"
|
||||
// Names the place, so it is clear before pressing where the note lands.
|
||||
title={place ? `New note in ${place}` : "New Note"}
|
||||
data-add-menu={open ? "on" : "off"}
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -430,6 +403,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches rearranging on.
|
||||
*
|
||||
* Rows are dragged rarely and read constantly, so the handles are not there by
|
||||
* default: the list stays a list, and taking hold of a row is something asked
|
||||
* for. While it is on, and only then, every row shows its grip and can be
|
||||
* carried; pressing a row does not open it by accident.
|
||||
*/
|
||||
const ReorderToggle = ({ T, on, onToggle }) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
data-reorder-toggle={on ? "on" : "off"}
|
||||
title={on ? "Done rearranging" : "Rearrange the rows"}
|
||||
aria-label="Rearrange the rows"
|
||||
aria-pressed={on}
|
||||
style={{
|
||||
...iconButton(T),
|
||||
color: on ? "#fff" : T.muted,
|
||||
background: on ? T.accent : "transparent",
|
||||
borderColor: on ? T.accent : T.border,
|
||||
}}
|
||||
>
|
||||
{on ? I.check : I.grip}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** The middle column: every note in the selected folder, in the chosen order. */
|
||||
export function NoteList({
|
||||
T,
|
||||
@@ -459,8 +458,14 @@ export function NoteList({
|
||||
onChapterContextMenu,
|
||||
expandedChapter,
|
||||
renderChapterNotes,
|
||||
onMoveNote,
|
||||
onReorder,
|
||||
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
|
||||
listKey,
|
||||
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
|
||||
isCurrent,
|
||||
onFocusList,
|
||||
/** The place a new note would land in, named on the + button. */
|
||||
newNotePlace,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
onNoteContextMenu,
|
||||
@@ -469,10 +474,21 @@ export function NoteList({
|
||||
onImportMarkdown,
|
||||
onEmptyTrash,
|
||||
}) {
|
||||
/* Rearranging is a mode, switched on for as long as it is being done.
|
||||
*
|
||||
* It ends by itself when the list is left: coming back to a notebook to find
|
||||
* every row wearing a handle, because it was rearranged last week, is not
|
||||
* where anybody left off. */
|
||||
const [reordering, setReordering] = useState(false);
|
||||
// Off again on the way to another list, and whenever a hand-made arrangement
|
||||
// stops being in effect at all — an order the app chose cannot be dragged.
|
||||
useEffect(() => setReordering(false), [listKey, reorderable]);
|
||||
const moving = !!reorderable && reordering;
|
||||
|
||||
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
|
||||
browsers never fire from touch — on a phone the arrangement could not be
|
||||
changed at all, with nothing on screen to say why. */
|
||||
const dnd = useReorderDrag(!!reorderable, onReorder);
|
||||
const dnd = useReorderDrag(moving, onReorder);
|
||||
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
|
||||
or the trash, which are views over notebooks rather than a place to file
|
||||
anything, and not inside a chapter, because a chapter holds notes. */
|
||||
@@ -484,6 +500,7 @@ export function NoteList({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
|
||||
style={{
|
||||
width: isMobile ? "100%" : 300,
|
||||
minWidth: isMobile ? 0 : 300,
|
||||
@@ -493,6 +510,8 @@ export function NoteList({
|
||||
flexDirection: "column",
|
||||
background: T.bg,
|
||||
fontFamily: FONT_UI,
|
||||
// A line along the top of whichever pane is being worked in.
|
||||
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -541,12 +560,21 @@ export function NoteList({
|
||||
>
|
||||
{openChapter ? openChapter.name : title}
|
||||
</h2>
|
||||
{/* Said in words as well as in the line above, because a coloured edge
|
||||
is only a hint until you know what it means. */}
|
||||
{isCurrent && (
|
||||
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{reorderable && (
|
||||
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
|
||||
)}
|
||||
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
|
||||
{!isTrashView && (
|
||||
<AddMenu
|
||||
T={T}
|
||||
place={newNotePlace}
|
||||
onCreateNote={onCreateNote}
|
||||
onImportPdf={onImportPdf}
|
||||
onImportMarkdown={onImportMarkdown}
|
||||
@@ -615,12 +643,20 @@ export function NoteList({
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{/* Only while it is on, and then it says what to do rather than
|
||||
explaining the order the sort menu already names. */}
|
||||
{moving && (
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
|
||||
Drag the rows into place
|
||||
</span>
|
||||
)}
|
||||
{/* A chapter divides the notebook; a separator divides a list. They
|
||||
sit together because both are "add something that is not a note",
|
||||
and apart from the + button, which makes notes. */}
|
||||
@@ -666,7 +702,16 @@ export function NoteList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||||
{/* Pressing in the list itself — on a note or on the empty space below
|
||||
them — says "I am in the notebook", and a new note goes there.
|
||||
Deliberately not the whole pane: the + button sits in the header, and
|
||||
taking the focus back on the way to pressing it would mean a note
|
||||
could never be made in the open chapter at all. */}
|
||||
<div
|
||||
data-note-list-body
|
||||
onPointerDown={onFocusList}
|
||||
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{notes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -718,14 +763,14 @@ export function NoteList({
|
||||
? mergeEntries(notes, separators, shownChapters)
|
||||
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
|
||||
).map((entry) => {
|
||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||
const showDropLine = moving && dnd.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
*
|
||||
* Putting these on the inner element left the indicator itself as
|
||||
* a dead strip: it pushes the row down as it appears, so the
|
||||
* pointer ended up over the line rather than over the row, and
|
||||
* releasing there dropped on nothing. */
|
||||
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
|
||||
const rowProps = moving ? dnd.rowProps(entry.id) : {};
|
||||
const lifted = dnd.draggingId === entry.id;
|
||||
|
||||
if (entry.kind === "chapter") {
|
||||
@@ -733,7 +778,7 @@ export function NoteList({
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<ChapterRow
|
||||
T={T}
|
||||
chapter={entry.chapter}
|
||||
@@ -752,7 +797,7 @@ export function NoteList({
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
grip={<Grip T={T} {...dnd.gripProps} />}
|
||||
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
|
||||
T={T}
|
||||
separator={entry.separator}
|
||||
editing={renamingSeparator === entry.id}
|
||||
@@ -770,10 +815,7 @@ export function NoteList({
|
||||
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
||||
{onMoveNote && !note.trashed && (
|
||||
<MoveButton T={T} onClick={() => onMoveNote(note)} />
|
||||
)}
|
||||
{moving && <Grip T={T} {...dnd.gripProps} />}
|
||||
<button
|
||||
data-note-row={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
@@ -787,7 +829,7 @@ export function NoteList({
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||
cursor: reorderable ? "grab" : "pointer",
|
||||
cursor: moving ? "grab" : "pointer",
|
||||
marginBottom: 2,
|
||||
textAlign: "left",
|
||||
}}
|
||||
@@ -848,7 +890,7 @@ export function NoteList({
|
||||
})}
|
||||
{/* Dropping below the last row moves something to the end, which
|
||||
otherwise needs an awkward drop onto the very last note. */}
|
||||
{reorderable && (
|
||||
{moving && (
|
||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
|
||||
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
|
||||
@@ -498,6 +498,13 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
folderId: n.notebook_id,
|
||||
/* Where it is filed inside the notebook.
|
||||
*
|
||||
* Missing here, a note put into a chapter came back out of it seconds later:
|
||||
* the server had it right, but every sync replaces the local mirror with
|
||||
* what this function returns — and it returned a note belonging to no
|
||||
* chapter, so the notebook's own list took it back. */
|
||||
chapterId: n.chapter_id ?? null,
|
||||
starred: !!n.starred,
|
||||
trashed,
|
||||
createdAt: n.created_at,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as sync from "../syncEngine";
|
||||
import { I } from "./icons.jsx";
|
||||
|
||||
/* ═══════ ACCOUNT MENU ═══════ */
|
||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
|
||||
/* Escape closes it, like every other panel over the app. Only clicking beside
|
||||
it worked, which on a panel this wide means aiming at a sliver of page. */
|
||||
useEffect(() => {
|
||||
const key = (e) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", key);
|
||||
return () => document.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
|
||||
const choosePreference = async (change) => {
|
||||
try {
|
||||
onUserUpdate(await sync.updatePreferences(change));
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { I } from "./icons.jsx";
|
||||
import { coverBackground } from "./constants.jsx";
|
||||
|
||||
/**
|
||||
* Where a note should go.
|
||||
*
|
||||
* Moving used to live only under a right-click, behind a submenu — two steps
|
||||
* before the list of places even appeared, and on a phone a long press followed
|
||||
* by a flyout. This is the same list, reached by one press of a button on the
|
||||
* row.
|
||||
*
|
||||
* Everything is on one screen: the chapters of the notebook the note is already
|
||||
* in, and then the other notebooks. Sending it to another notebook takes it out
|
||||
* of whatever chapter it was in, because a chapter belongs to the book it is in
|
||||
* — which is worth saying here rather than leaving to be discovered.
|
||||
*/
|
||||
export function MoveNoteDialog({ note, notebooks, chapters, onMove, onClose, dark }) {
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
row: dark ? "#0d1117" : "#f6f8fa",
|
||||
hover: dark ? "#21262d" : "#eaeef2",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
const here = chapters.filter((c) => c.notebookId === note.folderId);
|
||||
const others = notebooks.filter((nb) => nb.id !== note.folderId);
|
||||
|
||||
const go = (folderId, chapterId) => {
|
||||
onMove(note.id, folderId, chapterId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const Row = ({ children, onClick, disabled, dot, icon }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-move-target
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "11px 12px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: disabled ? T.row : "transparent",
|
||||
color: disabled ? T.muted : T.text,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
fontSize: 13,
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => !disabled && (e.currentTarget.style.background = T.hover)}
|
||||
onMouseLeave={(e) => !disabled && (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
{dot && <div style={{ width: 12, height: 16, borderRadius: 3, background: dot, flexShrink: 0 }} />}
|
||||
{icon && <span style={{ display: "flex", opacity: 0.7, flexShrink: 0 }}>{icon}</span>}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{children}</span>
|
||||
{disabled && <span style={{ marginLeft: "auto", fontSize: 11, flexShrink: 0 }}>here now</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const heading = (text) => (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
color: T.muted,
|
||||
margin: "12px 12px 4px",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-move-dialog
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 4000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
maxHeight: "80vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16,
|
||||
padding: "20px 10px 12px",
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 12px 4px", fontSize: 16, fontWeight: 700, color: T.text }}>Move note</h3>
|
||||
<div
|
||||
style={{
|
||||
margin: "0 12px 8px",
|
||||
fontSize: 12,
|
||||
color: T.muted,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{note.title || "Untitled"}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{here.length > 0 && heading("In this notebook")}
|
||||
{here.length > 0 && (
|
||||
<Row icon={I.note} disabled={!note.chapterId} onClick={() => go(note.folderId, null)}>
|
||||
The notebook itself
|
||||
</Row>
|
||||
)}
|
||||
{here.map((c) => (
|
||||
<Row
|
||||
key={c.id}
|
||||
icon={I.books}
|
||||
disabled={note.chapterId === c.id}
|
||||
onClick={() => go(note.folderId, c.id)}
|
||||
>
|
||||
{c.name}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
{others.length > 0 && heading("Another notebook")}
|
||||
{others.map((nb) => (
|
||||
<Row key={nb.id} dot={coverBackground(nb.cover, nb.coverPattern)} onClick={() => go(nb.id, null)}>
|
||||
{nb.name}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
{here.length === 0 && others.length === 0 && (
|
||||
<div style={{ padding: "18px 12px", fontSize: 12.5, color: T.muted }}>
|
||||
There is nowhere else to put it yet — make another notebook, or a chapter in this one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 12px 0" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user