Marks, lines, and a table that breaks

A long table lay straight across the gap between two sheets with a row
cut in half, and a shorter one jumped to the next sheet whole leaving
the rest of the first blank. Tables are broken between their rows now,
which is where a table breaks on paper. The spacer there is a tr with
one borderless cell: a div inside a tbody is not something the browser
keeps. Its own spacer rows are subtracted again on the next measurement
and counted into the shift every block below inherits — miss that and
everything under a broken table is measured a page too high.

A dialog closed in the middle of an edit if a selection was dragged past
its edge: the click lands on the nearest common ancestor of press and
release, which is the backdrop, and every dialog read that as "clicked
beside me". They remember where the press began now. All ten.

A note and a chapter can wear a mark of their own beside the name,
chosen from a short list searched by what a thing is for rather than
from the system emoji keyboard — a different thing on every machine, and
three thousand pictures for a decision that is "which of these is the
invoice notebook". A separator takes its own colour and one of five
shapes, and a chapter can be divided like the notebook it sits in: both
places a chapter is shown draw it with one component now, so it cannot
be divisible in the column and not in the unfolded block.

Also: a chapter's colour could not be cleared, because the update rode
on COALESCE and null there means "leave it alone". A table's header row
is legible on the dark theme. Three entries in the table's own menu were
still German. Opening a notebook opens its first note, on a wide screen
only — on a phone the list is the screen.

Fourteen suites green: 364 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-16 23:23:16 +02:00
1 parent 0c312e53c2
commit f1d3e38f8f
29 files changed
+1335 -149

No files matched your search

+122 -90
View File
@@ -8,11 +8,12 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx";
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 { IconPicker } from "./ui/IconPicker.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
@@ -23,7 +24,7 @@ import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx";
import { ImportDialog } from "./ui/ImportDialog.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx";
import {
DEFAULT_NOTEBOOK_SORT,
DEFAULT_SORT,
@@ -99,6 +100,9 @@ export default function App() {
/* The one dialog that asks for a name — a chapter's, with its colour, or a
separator's. Held here because both are started from menus that live here. */
const [naming, setNaming] = useState(null);
/* { title, value, colour, onSave } while a mark is being chosen for a note or
a chapter — one picker, because it is one decision either way. */
const [pickingIcon, setPickingIcon] = 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);
@@ -277,17 +281,30 @@ export default function App() {
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) !== listChapter), ...visible]);
const writeIn = (chapterId, visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== chapterId), ...visible]);
const writeVisible = (visible) => writeIn(listChapter, visible);
/* One press draws the line; it is named afterwards from its own menu, like it
is renamed. A dialog in front of it asked a question about something that
reads perfectly well unnamed. */
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
/* The same act inside a chapter. A chapter is a list like the notebook is, so
it can be divided like one — its lines carry its id and stay in it. */
const addChapterSeparator = () =>
activeChapter &&
writeIn(
activeChapter,
withNewSeparator(chapterSeparators, `sep-${Date.now().toString(36)}`, "", activeChapter)
);
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
/* Colour and pattern are presentation, so they are written the same way the
label is: onto the notebook's one list, leaving the others untouched. */
const restyleSeparator = (id, changes) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s)));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
@@ -297,6 +314,26 @@ export default function App() {
y: e.clientY,
items: [
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => restyleSeparator(separator.id, { color: null }) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => restyleSeparator(separator.id, { color: c.value }),
})),
],
},
{
label: "Line",
icon: I.edit,
submenu: SEPARATOR_STYLES.map((st) => ({
label: st.label,
action: () => restyleSeparator(separator.id, { style: st.value }),
})),
},
{ divider: true },
{
label: "Remove separator",
@@ -357,6 +394,25 @@ export default function App() {
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, sortMode]
);
/* And the lines drawn inside it, which are its own — the notebook's stay in
the notebook, and each chapter's stay in that chapter. */
const chapterSeparators = useMemo(
() => separatorsFor(allSeparators, activeChapter),
[allSeparators, activeChapter]
);
/* Opening a notebook opens its first note with it.
*
* On a wide screen the editor is already there, and leaving it on "Select a
* note to edit" wastes two thirds of the window and a click on the thing you
* almost always want. On a phone the list *is* the screen, so opening a note
* would replace it and hide the notebook you just asked to see. */
useEffect(() => {
if (isMobile || activeNote || isTrashView) return;
if (activeFolder === "dashboard" || isSearching) return;
const first = filteredNotes[0];
if (first) setActiveNote(first.id);
}, [isMobile, activeNote, isTrashView, activeFolder, activeChapter, isSearching, filteredNotes]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -501,6 +557,19 @@ export default function App() {
openNote(note.id);
});
/* A note's own mark. Written through its own call rather than with the title:
the title write is debounced behind typing, and a mark chosen with one press
would then arrive late or not at all. */
const pickNoteIcon = () => {
if (!noteMeta) return;
setPickingIcon({
title: "Icon for this note",
value: noteMeta.icon ?? null,
colour: noteMeta.iconColor ?? null,
onSave: (icon, colour) => data.setNoteIcon(noteMeta.id, icon, colour),
});
};
/**
* Runs a destructive action behind a confirmation prompt.
*
@@ -685,6 +754,20 @@ export default function App() {
onSave: (name, color) => data.editChapter(chapter.id, { name, color }),
}),
},
/* The mark is its own entry: a name and a colour are typed and picked
from a short list, a mark is hunted for in a grid — one dialog doing
both would be a form. */
{
label: "Icon",
icon: I.edit,
action: () =>
setPickingIcon({
title: `Icon for "${chapter.name}"`,
value: chapter.icon ?? null,
colour: chapter.color ?? null,
onSave: (icon, colour) => data.editChapter(chapter.id, { icon, color: colour }),
}),
},
{ divider: true },
{
label: "Remove chapter",
@@ -767,6 +850,28 @@ export default function App() {
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
/* What a chapter holds, wherever it is shown. The column beside the list and
this unfolded block are the same list in two frames, so they draw it with
the same component — a chapter divisible in one and not the other would be
two different things under one name. */
const chapterContents = (compact) => (
<ChapterNotes
T={T}
notes={chapterNotes}
separators={chapterSeparators}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
activeNote={activeNote}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
onAddSeparator={addChapterSeparator}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
compact={compact}
/>
);
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
@@ -783,48 +888,7 @@ export default function App() {
paddingLeft: 8,
}}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
{chapterContents(true)}
</div>
);
@@ -1014,50 +1078,7 @@ export default function App() {
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
{chapterContents(false)}
</div>
</div>
)}
@@ -1074,6 +1095,7 @@ export default function App() {
linkProps={linkProps}
onBack={closeNote}
onUpdate={(u) => data.updateNote(activeNote, u)}
onPickIcon={pickNoteIcon}
onToggleStar={() => data.toggleStar(activeNote)}
onTrash={() => trashNote(activeNote)}
onRestore={() => restoreNote(activeNote)}
@@ -1188,6 +1210,16 @@ export default function App() {
dark={dark}
/>
)}
{pickingIcon && (
<IconPicker
title={pickingIcon.title}
value={pickingIcon.value}
colour={pickingIcon.colour}
onSave={pickingIcon.onSave}
onClose={() => setPickingIcon(null)}
dark={dark}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
+29 -2
View File
@@ -21,6 +21,12 @@
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
/* A table's header row, which needs to read as one at a glance. Its own
tokens because "slightly off the page colour" is enough for a code block
sitting in the middle of text and not enough for a row that has to be told
from the rows under it. */
--ne-th-bg: #eaeef2;
--ne-th-rule: #afb8c1;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -30,6 +36,10 @@
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
/* Dark is where this mattered: #161b22 on a #0d1117 sheet is a difference you
have to look for, so a header row read as just another row. */
--ne-th-bg: #262c36;
--ne-th-rule: #6a7480;
}
.note-editor .ProseMirror {
@@ -167,6 +177,20 @@
pointer-events: none;
}
/* The spacer that carries a table across the gap between two sheets. It is a
row, so it inherits every cell rule below — and has to shed all of them, or
the gap shows a tall bordered box. */
.note-editor .ProseMirror tr.ne-page-spacer-row,
.note-editor .ProseMirror tr.ne-page-spacer-row td {
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.note-editor .ProseMirror tr.ne-page-spacer-row td::before,
.note-editor .ProseMirror tr.ne-page-spacer-row td::after {
content: none !important;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
@@ -184,9 +208,12 @@
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
background: var(--ne-th-bg);
font-weight: 700;
text-align: left;
/* The rule under the header does most of the work — the fill alone cannot be
made much stronger without the header turning into a banner. */
border-bottom: 2px solid var(--ne-th-rule);
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
+132 -14
View File
@@ -17,14 +17,24 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view";
* note is still one body.
*
* The unit is a whole block. A paragraph that would be split across the gap
* moves down entire, which is what a page break means on paper. A single block
* taller than a page — a long table, a big image — is left where it is and runs
* over the gap: splitting one would mean laying out its insides ourselves.
* moves down entire, which is what a page break means on paper.
*
* A **table** is the exception, and has to be: moved whole it either jumps to
* the next page leaving half a sheet blank, or — being taller than a page — has
* nowhere to jump to and lies across the gap regardless. So a table is broken
* between its rows, which is where a table breaks on paper too. The spacer there
* is a row of its own rather than a block: a `div` inside a `tbody` is not a
* thing, and a `tr` with one borderless cell pushes the rows after it down
* exactly as needed.
*
* Everything else too tall to fit anywhere — a full-page image — is left where
* it is: moving it down only puts the same problem one page further on.
*/
export const paginationKey = new PluginKey("pagination");
const SPACER_CLASS = "ne-page-spacer";
const SPACER_ROW_CLASS = "ne-page-spacer-row";
const px = (value) => {
const n = parseFloat(value);
@@ -55,6 +65,59 @@ function makeSpacer(height, delta) {
* Returns `null` when there is nothing to measure against — no geometry yet, or
* a page too short to hold anything.
*/
/**
* The spacer inside a table.
*
* A row, because that is what a table's children are: anything else is either
* dropped by the browser or wrapped in an anonymous cell of its own. The cell
* carries the height and is stripped of the borders every other cell has, so the
* gap between two sheets stays empty.
*/
function makeSpacerRow(height, delta, colspan) {
const tr = document.createElement("tr");
tr.className = SPACER_ROW_CLASS;
tr.setAttribute("aria-hidden", "true");
tr.contentEditable = "false";
tr.dataset.delta = String(delta);
const td = document.createElement("td");
td.colSpan = Math.max(1, colspan || 1);
td.style.height = `${Math.max(0, Math.round(height))}px`;
tr.appendChild(td);
return tr;
}
/** The rows of a table, with the document position of each. */
function tableRows(node, wrapper, domTop, seenBefore) {
const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table");
const body = table?.querySelector(":scope > tbody");
if (!body) return null;
const rows = [];
let inner = 0;
let index = 0;
// Positions are walked rather than looked up: a row's place in the document is
// the table's own position plus the size of every row before it.
let pos = 0;
for (const el of body.children) {
if (el.classList?.contains(SPACER_ROW_CLASS)) {
inner += Number(el.dataset.delta) || 0;
continue;
}
if (index >= node.childCount) break;
const rect = el.getBoundingClientRect();
rows.push({
index,
pos,
top: rect.top - domTop - seenBefore - inner,
height: rect.height,
});
pos += node.child(index).nodeSize;
index++;
}
const columns = table.querySelector(":scope > colgroup")?.children.length || 1;
return { rows, inner, columns };
}
function computeBreaks(view, geometry) {
const pageHeight = geometry?.pageHeight ?? 0;
const gap = geometry?.gap ?? 0;
@@ -71,9 +134,9 @@ function computeBreaks(view, geometry) {
const domTop = dom.getBoundingClientRect().top;
// Top-level document positions, in the order they are rendered.
const positions = [];
view.state.doc.forEach((_node, offset) => positions.push(offset));
// Top-level nodes and their positions, in the order they are rendered.
const nodes = [];
view.state.doc.forEach((node, offset) => nodes.push({ node, offset }));
/* Each block's position **as if no spacer existed**. A spacer shifts
* everything after it by exactly its recorded delta, so subtracting the
@@ -87,16 +150,33 @@ function computeBreaks(view, geometry) {
seen += Number(child.dataset.delta) || 0;
continue;
}
if (index >= positions.length) break;
if (index >= nodes.length) break;
const { node, offset } = nodes[index];
const rect = child.getBoundingClientRect();
const cs = getComputedStyle(child);
blocks.push({
pos: positions[index],
const block = {
pos: offset,
top: rect.top - domTop - seen,
height: rect.height,
marginTop: px(cs.marginTop),
marginBottom: px(cs.marginBottom),
});
};
/* A table is measured row by row, since that is where it can be broken.
* Its own spacer rows are subtracted as we go, and then added to `seen`:
* they push everything after the table down as surely as a block spacer
* does, and the next block's natural position has to allow for it. */
if (node.type.name === "table") {
const measured = tableRows(node, child, domTop, seen);
if (measured) {
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
block.columns = measured.columns;
block.height = rect.height - measured.inner;
seen += measured.inner;
}
}
blocks.push(block);
index++;
}
@@ -105,6 +185,40 @@ function computeBreaks(view, geometry) {
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const top = block.top + shift;
/* ── a table breaks between its rows ──
* The row that would straddle the gap starts the next page instead, and the
* rows above it stay where they are. Moving the table whole either wastes
* the rest of the sheet or, for a table longer than a page, cannot help at
* all — which is why a long one used to lie straight across the gap. */
if (block.rows?.length) {
let inner = 0;
for (const row of block.rows) {
// `shift` is what the blocks above pushed down; `inner` is what the
// breaks already placed inside this table added on top of that.
const rowTop = row.top + shift + inner;
const rowPage = Math.floor(rowTop / pitch);
const rowEnd = rowPage * pitch + pageHeight - padBottom;
if (rowTop + row.height <= rowEnd + 0.5) continue;
// A row taller than a whole page cannot be helped by moving it.
if (row.height > usable) continue;
const delta = (rowPage + 1) * pitch + padTop - rowTop;
if (delta <= 0) continue;
/* The first row is the table's own top edge: pushing *inside* the table
* there would leave its border hanging above the break, so the whole
* table moves, exactly as any other block would. */
if (row.index === 0) {
if (i === 0) continue;
breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - Math.min(blocks[i - 1].marginBottom, block.marginTop)) });
} else {
breaks.push({ pos: row.pos, delta, height: delta, row: true, columns: block.columns });
}
inner += delta;
}
shift += inner;
continue;
}
// Taller than a page however it is placed — moving it down would only put
// the same problem on the next page, one page further from the top.
if (block.height > usable) continue;
@@ -133,20 +247,24 @@ const sameBreaks = (a, b) =>
!!a &&
!!b &&
a.length === b.length &&
a.every((x, i) => x.pos === b[i].pos && Math.abs(x.height - b[i].height) < 1);
a.every((x, i) => x.pos === b[i].pos && !!x.row === !!b[i].row && Math.abs(x.height - b[i].height) < 1);
const decorationsFor = (doc, breaks) =>
DecorationSet.create(
doc,
breaks.map((b) =>
Decoration.widget(b.pos, () => makeSpacer(b.height, b.delta), {
Decoration.widget(
b.pos,
() => (b.row ? makeSpacerRow(b.height, b.delta, b.columns) : makeSpacer(b.height, b.delta)),
{
// Height in the key, so a spacer that has to change size is rebuilt
// rather than reused at its old height.
key: `page-break-${b.pos}-${Math.round(b.height)}`,
key: `page-break-${b.row ? "row" : "block"}-${b.pos}-${Math.round(b.height)}`,
side: -1,
ignoreSelection: true,
marks: [],
})
}
)
)
);
+3 -3
View File
@@ -38,13 +38,13 @@ export function tableMenuItems(editor) {
{ label: "Insert column left", action: run((c) => c.addColumnBefore()) },
{ label: "Insert column right", action: run((c) => c.addColumnAfter()) },
{ divider: true },
{ label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) },
{ label: "Header row on/off", action: run((c) => c.toggleHeaderRow()) },
];
// mergeCells and splitCell are mutually exclusive in practice: one needs a
// multi-cell selection, the other a cell that was merged earlier.
if (can("mergeCells")) items.push({ label: "Zellen verbinden", action: run((c) => c.mergeCells()) });
if (can("splitCell")) items.push({ label: "Zelle teilen", action: run((c) => c.splitCell()) });
if (can("mergeCells")) items.push({ label: "Merge cells", action: run((c) => c.mergeCells()) });
if (can("splitCell")) items.push({ label: "Split cell", action: run((c) => c.splitCell()) });
items.push(
{ divider: true },
@@ -146,6 +146,7 @@ export function EditorPane({
linkProps,
onBack,
onUpdate,
onPickIcon,
onToggleStar,
onTrash,
onRestore,
@@ -192,6 +193,31 @@ export function EditorPane({
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
{/* The note's own mark, left of its name — where the eye goes first and
where the same mark sits in the list. Pressing it opens the picker. */}
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: note.icon ? 17 : 14,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
+3 -1
View File
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../../syncEngine";
import { VersionPreview } from "../../editor/VersionPreview.jsx";
import { I } from "../../ui/icons.jsx";
import { useBackdropClose } from "../../ui/backdrop";
/* ═══════ VERSION HISTORY ═══════ */
/* Past states of a note, newest first. The server keeps a snapshot of what a
* note looked like before each editing session, so an overwrite that is only
* noticed later can still be undone. */
export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, renderDrawing }) {
const backdrop = useBackdropClose(onClose);
const [versions, setVersions] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
@@ -110,7 +112,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+192 -10
View File
@@ -76,6 +76,40 @@ const MIDDLE_MIN = 54;
/** Widest, so a long name cannot squeeze the rules away to nothing. */
const MIDDLE_MAX = 170;
/**
* How a separator's rule is drawn.
*
* Built as a border rather than a background, because that is the only way to
* get dashes and dots without an image — and `double` needs the height to carry
* two lines and the gap between them, or the browser draws one.
*/
const RULE_STYLES = {
solid: { height: 2, borderRadius: 2, solid: true },
dashed: { height: 0, borderTop: "2px dashed" },
dotted: { height: 0, borderTop: "3px dotted" },
double: { height: 0, borderTop: "4px double" },
thick: { height: 4, borderRadius: 2, solid: true },
};
/** One half of the line, left or right of the label. */
function Rule({ colour, style }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: 1,
minWidth: 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid
? { background: colour }
: { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
/**
* A line drawn between notes, with an optional name.
*
@@ -89,6 +123,9 @@ const MIDDLE_MAX = 170;
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
// Its own colour when it has been given one; the list's faint grey otherwise,
// which is what every separator looked like before there was a choice.
const colour = separator.color || T.faint;
useEffect(() => {
if (editing) setDraft(separator.label);
@@ -115,11 +152,8 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
}}
>
{grip}
{/* The two rules always share what is left over equally, and the middle
keeps one width whether it is showing a label or an input. Sizing it to
its contents made the whole row jump sideways the moment renaming
started, which is exactly when the eye is on it. */}
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* Sized to the name it holds, between a floor and a ceiling. A fixed slot
kept the row from jumping when renaming began, at the cost of a short
name floating in the middle of a gap it did not fill. */}
@@ -173,7 +207,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? T.text : T.muted,
color: separator.label ? separator.color || T.text : T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
@@ -189,7 +223,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
</div>
)}
</div>
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<Rule colour={colour} style={separator.style} />
</div>
);
}
@@ -224,7 +258,19 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
>
{/* Its own colour, so a shelf of chapters can be told apart at a glance
— the point of having them is that they are different places. */}
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
<span
data-chapter-mark
style={{
display: "flex",
alignItems: "center",
color: chapter.color || T.accent,
flexShrink: 0,
fontSize: chapter.icon ? 15 : undefined,
lineHeight: 1,
}}
>
{chapter.icon || I.books}
</span>
<span
style={{
flex: 1,
@@ -245,6 +291,129 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
);
}
/**
* What is inside a chapter: its notes, with its own lines drawn between them.
*
* One component for both places a chapter can be shown — the column beside the
* list and the unfolded block under its row — because they are the same list in
* two frames, and a chapter that could be divided in one and not the other would
* be two different things wearing one name.
*
* A chapter's separators are its own. Each carries the list it was drawn in, so
* the notebook's lines stay in the notebook and these stay here.
*/
export function ChapterNotes({
T,
notes,
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// Lines only mean something where the order is the user's own, exactly as in
// the notebook's list — in "newest first" the notes slide past them.
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{entries.map((entry) =>
entry.kind === "separator" ? (
<SeparatorRow
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
) : (
<button
key={entry.id}
data-chapter-note={entry.id}
onClick={() => onSelectNote(entry.id)}
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: compact ? "8px 10px" : "9px 10px",
borderRadius: compact ? 8 : 9,
border: `1px solid ${activeNote === entry.id ? T.accent + "55" : "transparent"}`,
background: activeNote === entry.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: entry.note.iconColor || T.faint,
flexShrink: 0,
fontSize: entry.note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{entry.note.icon || (entry.note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.note.title}
</span>
</button>
)
)}
{/* A chapter can be divided like the notebook it sits in. Only where a
line can be seen, same as in the list outside. */}
{reorderable && onAddSeparator && (
<button
onClick={onAddSeparator}
data-add-chapter-separator
title="Insert a separator line in this chapter"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
marginTop: 6,
}}
>
+ Separator
</button>
)}
</>
);
}
/**
* How this notebook's notes are ordered.
*
@@ -868,8 +1037,21 @@ export function NoteList({
width: "100%",
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note}
{/* Its own mark when it has one, otherwise the kind of
thing it is. The same glyph the editor shows, so a note
is recognised by the same sign in both places. */}
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: note.iconColor || T.faint,
flexShrink: 0,
fontSize: note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{note.trashed ? I.trash : note.icon || (note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
data-note-title
+8
View File
@@ -41,6 +41,8 @@ export interface NoteData {
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => Promise<void>;
/** The note's own mark beside its title, and the colour it is drawn in. */
setNoteIcon: (id: string, icon: string | null, iconColor: string | null) => Promise<void>;
trashNote: (id: string) => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
@@ -249,6 +251,11 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
setNoteContents((p) => ({ ...p, [id]: content }));
}, []);
const setNoteIcon = useCallback(async (id: string, icon: string | null, iconColor: string | null) => {
setNotes((p) => p.map((n) => (n.id === id ? { ...n, icon, iconColor } : n)));
await sync.updateNoteIcon(id, icon, iconColor);
}, []);
const toggleStar = useCallback(async (id: string) => {
const starred = await sync.toggleNoteStar(id);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
@@ -427,6 +434,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
updateNote,
setNoteContent,
toggleStar,
setNoteIcon,
trashNote,
restoreNote,
permanentDeleteNote,
+46 -2
View File
@@ -84,6 +84,7 @@ interface ServerChapter {
notebook_id: string;
name: string;
color?: string | null;
icon?: string | null;
sort_order?: number;
}
@@ -92,6 +93,8 @@ interface ServerNote {
title: string;
notebook_id: string | null;
chapter_id?: string | null;
icon?: string | null;
icon_color?: string | null;
starred?: number | boolean;
created_at: string;
updated_at?: string;
@@ -127,6 +130,9 @@ export interface NoteUpdate {
*/
chapterId?: string | null;
starred?: boolean;
/** The note's own mark, and its colour. Null on either takes it off. */
icon?: string | null;
iconColor?: string | null;
links?: string[];
/** Attachments the body embeds, so ones nothing uses can be released. */
attachments?: string[];
@@ -505,6 +511,8 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
* 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,
icon: n.icon ?? null,
iconColor: n.icon_color ?? null,
starred: !!n.starred,
trashed,
createdAt: n.created_at,
@@ -538,7 +546,10 @@ type QueuedAction =
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string; color?: string | null } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number; color?: string | null } }
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
}
| { action: "deleteChapter"; data: { id: string } }
| {
action: "createNote";
@@ -677,6 +688,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: a.data.name,
sortOrder: a.data.sortOrder,
color: a.data.color,
icon: a.data.icon,
});
break;
case "deleteChapter":
@@ -810,6 +822,7 @@ export async function fetchChapters(): Promise<Chapter[]> {
notebookId: c.notebook_id,
name: c.name,
color: c.color ?? null,
icon: c.icon ?? null,
sortOrder: c.sort_order || 0,
}));
const local = await db.getAllChapters();
@@ -849,7 +862,7 @@ export async function createChapter(
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null }
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
@@ -1149,6 +1162,37 @@ export async function createNote(
return note;
}
/**
* Sets or clears a note's own mark.
*
* Its own call rather than a field on the title write: the mark is chosen from a
* picker, one press at a time, and debouncing it behind the title's timer would
* make it arrive late or not at all.
*/
export async function updateNoteIcon(
id: string,
icon: string | null,
iconColor: string | null
): Promise<void> {
const note = await db.getNote(id);
if (!note) return;
const updated: NoteMeta = { ...note, icon, iconColor };
await db.putNote(updated);
notifyData("note", updated);
const updates = { icon, iconColor };
if (_online && api.token && !updated._pendingCreate) {
try {
await api.put(`/notes/${id}`, updates);
return;
} catch {
/* queue it below */
}
}
await enqueue({ action: "updateNote", data: { id, updates } });
notifyStatus("pending");
}
export async function updateNoteTitle(id: string, title: string): Promise<void> {
const note = await db.getNote(id);
if (!note) return;
+18
View File
@@ -47,6 +47,9 @@ export interface NoteMeta {
folderId: string | null;
/** Chapter within the notebook; null means the notebook itself. */
chapterId?: string | null;
/** Its own mark beside the title, and the colour that mark is drawn in. */
icon?: string | null;
iconColor?: string | null;
starred: boolean;
trashed: boolean;
createdAt: string;
@@ -82,6 +85,12 @@ export type SortMode = "manual" | "updated" | "oldest" | "created" | "title";
* `order` is "how many notes come before me", so it stays meaningful as the
* notes around it are renamed, edited or opened.
*/
/** A mark a row can wear beside its name, with a colour of its own. */
export interface RowIcon {
icon?: string | null;
iconColor?: string | null;
}
export interface Separator {
id: string;
label: string;
@@ -92,8 +101,15 @@ export interface Separator {
* anything within one list.
*/
chapterId?: string | null;
/** Its own colour, `#rrggbb`, or null for the list's own faint grey. */
color?: string | null;
/** How the rule is drawn: solid, dashed, dotted, double or a thick band. */
style?: SeparatorStyle;
}
/** The ways a separator's rule can be drawn. */
export type SeparatorStyle = "solid" | "dashed" | "dotted" | "double" | "thick";
/**
* A folder inside a notebook.
*
@@ -107,6 +123,8 @@ export interface Chapter {
name: string;
/** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */
color?: string | null;
/** An emoji standing in for the default folder mark. */
icon?: string | null;
sortOrder: number;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
+3 -1
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const backdrop = useBackdropClose(onClose);
/* 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(() => {
@@ -131,7 +133,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
+3 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
const MIN_PASSWORD_LENGTH = 6;
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
@@ -23,6 +24,7 @@ const fmtDate = (d) =>
* makes the user type the name.
*/
export function AdminPanel({ currentUser, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const [users, setUsers] = useState(null);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
@@ -176,7 +178,7 @@ export function AdminPanel({ currentUser, onClose, dark }) {
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
+3 -1
View File
@@ -1,7 +1,9 @@
import { useMemo } from "react";
import { useBackdropClose } from "./backdrop";
/* ═══════ Shared Components ═══════ */
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
const backdrop = useBackdropClose(onCancel);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
@@ -28,7 +30,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onCancel}
{...backdrop}
>
<div
style={{
+5 -2
View File
@@ -11,6 +11,7 @@ import {
} from "../export/exportNotes";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
/**
* Export as Markdown, PDF or PNG.
@@ -32,6 +33,8 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const [hasInk, setHasInk] = useState(null);
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
const [busy, setBusy] = useState(null);
// An export in flight is not interrupted by a stray click beside the dialog.
const backdrop = useBackdropClose(() => !busy && onClose());
const [progress, setProgress] = useState(null);
const [err, setErr] = useState("");
const [done, setDone] = useState("");
@@ -256,7 +259,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={() => !busy && onClose()}
{...backdrop}
>
<div
data-export-dialog
@@ -298,7 +301,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</div>
</div>
<button
onClick={() => !busy && onClose()}
{...backdrop}
style={{
display: "flex",
alignItems: "center",
+275
View File
@@ -0,0 +1,275 @@
import { useMemo, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing the mark a note or a chapter wears beside its name.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
*
* The words are what makes it usable: nobody hunts a pictogram grid by shape.
* They are also the reason the list is written out rather than generated.
*/
const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
];
/**
* The picker.
*
* `value` and `colour` are what the row wears now; "None" is always offered,
* because a mark that cannot be taken off again is a trap.
*/
export function IconPicker({ title, value = null, colour = null, onSave, onClose, dark }) {
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(value);
const [tint, setTint] = useState(colour);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
const save = (char, colourValue) => {
onSave(char, colourValue);
onClose();
};
return (
<div
data-icon-picker
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onClose()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
}}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
gap: 6,
marginTop: 14,
maxHeight: 240,
overflowY: "auto",
}}
>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => setPicked(icon.char)}
style={{
height: 42,
borderRadius: 10,
cursor: "pointer",
fontSize: 21,
lineHeight: 1,
border: picked === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: picked === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
))}
{shown.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
{/* The tint is for the marks that are drawn from the app's own set — a
coloured emoji ignores it, which is why it is offered quietly. */}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, marginBottom: 7 }}>
Colour (for the row, and for marks without one of their own)
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (tint ?? null);
return (
<button
key={c.label}
type="button"
data-icon-colour={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setTint(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
type="button"
data-icon-clear
onClick={() => save(null, null)}
style={{
padding: "8px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
cursor: "pointer",
fontSize: 13,
marginRight: "auto",
}}
>
No icon
</button>
<button
type="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>
<button
type="button"
data-icon-save
onClick={() => save(picked, tint)}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Attaching a web address to the selected text.
@@ -18,6 +19,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
const ALLOWED = /^(https?|mailto):/i;
export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const [url, setUrl] = useState(initialUrl);
const [error, setError] = useState("");
const inputRef = useRef(null);
@@ -88,7 +90,7 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
{...backdrop}
>
<form
onSubmit={submit}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Asking for one name.
@@ -28,6 +29,7 @@ export function NameDialog({
}) {
const [name, setName] = useState(value);
const [color, setColor] = useState(initialColor);
const backdrop = useBackdropClose(onClose);
const inputRef = useRef(null);
const T = useMemo(
@@ -58,7 +60,7 @@ export function NameDialog({
return (
<div
data-name-dialog
onClick={onClose}
{...backdrop}
style={{
position: "fixed",
inset: 0,
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing a cover: its colour, then the pattern over it.
@@ -76,6 +77,7 @@ function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
}
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState(notebook.name);
/* Colour and pattern are separate choices now. A notebook saved when they
were one list holds an index into that list, so it is read back as the pair
@@ -113,7 +115,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -237,6 +239,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const [pat, setPat] = useState(0);
@@ -262,7 +265,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
+3 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { coverBackground } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Asks which notebook something should go into.
@@ -33,6 +34,7 @@ const recalledNotebook = () => {
};
export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) {
const backdrop = useBackdropClose(onClose);
const remembered = recalledNotebook();
const [selected, setSelected] = useState(
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
@@ -77,7 +79,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+32
View File
@@ -0,0 +1,32 @@
import { useCallback, useRef } from "react";
/**
* Clicking beside a dialog closes it — but only a click that *began* there.
*
* A bare `onClick={onClose}` on the backdrop looks right and is wrong in one
* ordinary case: select a word inside the dialog, drag a little past its edge,
* let go. The browser fires the click on the nearest common ancestor of press
* and release, which is the backdrop — so the dialog closed in the middle of
* editing, and whatever had been typed went with it.
*
* Remembering where the press landed is enough to tell the two apart: a real
* dismissal starts and ends on the backdrop, a selection starts inside it.
*/
export function useBackdropClose(onClose) {
const startedOutside = useRef(false);
const onPointerDown = useCallback((e) => {
startedOutside.current = e.target === e.currentTarget;
}, []);
const onClick = useCallback(
(e) => {
if (e.target !== e.currentTarget || !startedOutside.current) return;
startedOutside.current = false;
onClose?.();
},
[onClose]
);
return { onPointerDown, onClick };
}
+14
View File
@@ -59,6 +59,20 @@ export const CHAPTER_COLORS = [
{ label: "Pink", value: "#db61a2" },
];
/**
* The ways a separator's rule can be drawn.
*
* Named rather than shown as samples: at this size a dotted rule and a dashed
* one are two grey smudges, and the word says which is which.
*/
export const SEPARATOR_STYLES = [
{ label: "Solid", value: "solid" },
{ label: "Dashed", value: "dashed" },
{ label: "Dotted", value: "dotted" },
{ label: "Double", value: "double" },
{ label: "Thick", value: "thick" },
];
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
* own set, so it matches the stroke weight of everything around it and looks
* the same on every machine. */