One dialog per thing, and rows dragged into a chapter

A separator had three menu entries — rename, colour, line — plus an input
that appeared in the row itself: three shapes of interaction for one small
thing, and none of them showed the result. Choosing "dotted" from a list
of words is a guess. It is one Edit now, and the dialog draws the line as
it will look with every shape drawn beside its name, from the very rules
the list draws with: a sample that can disagree with the result is worse
than none.

A chapter goes the same way — name, mark and colour in one dialog, the
same one whether it is being made or changed, with the row drawn at the
top. Asked from three entries, getting a new chapter to look right took
three visits. The name dialog it used is gone.

A row is put into a chapter by dragging it onto it: the chapter lights
up and takes what is dropped rather than growing a line above it, since
a chapter is a place and not a neighbour. Notes and separators both, and
the "+ Separator" inside a chapter goes with it — a second way to make
the same thing in a second place is a second thing to keep straight.

A link now wears the mark of the note it points at, in the text and in
the @ menu. It travels when the note's mark changes, as the label
already did.

Two alignment faults: a task list's checkbox was nudged down by a fixed
0.35em measured once against one font, so it floated above the line it
belonged to — it is centred in a box one line tall now. And the title
bar's mark was spaced like the buttons at the far end and grew with its
contents, so picking an emoji shifted the title sideways.

Fourteen suites green: 374 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-17 00:23:26 +02:00
1 parent 59844046c4
commit d68b0775c2
20 files changed
+1185 -594

No files matched your search

+79 -109
View File
@@ -12,7 +12,8 @@ import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } fr
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 { ChapterDialog } from "./ui/ChapterDialog.jsx";
import { SeparatorDialog } from "./ui/SeparatorDialog.jsx";
import { IconPicker } from "./ui/IconPicker.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
@@ -92,17 +93,16 @@ export default function App() {
store a choice — "All Notes" is not a thing that can carry a setting — so it
lasts for the session and no longer. */
const [virtualSort, setVirtualSort] = useState({});
// Which separator is being renamed. Held here because the context menu that
// starts it lives here too.
const [renamingSeparator, setRenamingSeparator] = useState(null);
// The separator being edited, if any. Held here because the menu that opens
// the dialog lives here too.
const [editingSeparator, setEditingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
/* 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. */
/* { title, value, colour, onSave } while a mark is being chosen for a note. */
const [pickingIcon, setPickingIcon] = useState(null);
/* The chapter being made or changed: `{}` for a new one, the chapter itself
for an existing one. */
const [editingChapter, setEditingChapter] = 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);
@@ -245,6 +245,7 @@ export default function App() {
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
[data, activeFolder, listChapter]
);
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
@@ -290,22 +291,39 @@ export default function App() {
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) =>
const editSeparator = (id, changes) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s)));
/**
* Dropping a row onto a chapter puts it *in* the chapter.
*
* A note is filed there; a line drawn in the notebook is moved into it, which
* is also how a chapter gets a line of its own — there is no second button for
* that, because a second way to make the same thing in a second place is a
* second thing to keep straight.
*/
const fileIntoChapter = useCallback(
(dragId, chapterId) => {
const note = notes.find((n) => n.id === dragId);
if (note) {
void data.moveNote(dragId, activeFolder, chapterId);
return;
}
const separator = allSeparators.find((sep) => sep.id === dragId);
if (separator) {
writeSeparators(
allSeparators.map((sep) => (sep.id === dragId ? { ...sep, chapterId, order: 0 } : sep))
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[notes, allSeparators, activeFolder, data]
);
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
e.stopPropagation();
@@ -313,27 +331,10 @@ export default function App() {
x: e.clientX,
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 }),
})),
},
/* One entry, like every other row here has. It used to be three —
rename, colour, line — three shapes of interaction for one small
thing, none of them showing what the result would look like. */
{ label: "Edit", icon: I.edit, action: () => setEditingSeparator(separator) },
{ divider: true },
{
label: "Remove separator",
@@ -436,7 +437,7 @@ export default function App() {
...notes
.filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled"))
.slice(0, 8)
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })),
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note", icon: n.icon || null })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
@@ -468,9 +469,16 @@ export default function App() {
[notes, openFolder, openNote]
);
/* The mark a link should wear: the target's own, so a reference is recognised
by the same sign as the note it points at. */
const resolveLinkIcon = useCallback(
(id) => notes.find((n) => n.id === id && !n.trashed)?.icon ?? null,
[notes]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel, resolveLinkIcon]
);
/* ── Actions ── */
@@ -715,20 +723,11 @@ export default function App() {
setInChapter(!!next);
};
/* Named and coloured in one go. The colour is what tells one chapter from the
next, so asking for it while the chapter is being made is one decision
rather than a second visit through the right-click menu. */
const addChapter = () => {
if (!activeNotebook) return;
setNaming({
title: "New chapter",
label: "Chapter name",
value: "",
colors: CHAPTER_COLORS,
color: null,
onSave: (name, color) => data.createChapter(activeNotebook.id, name, color),
});
};
/* Named, marked and coloured in one go, and the same dialog does both making
and changing. A chapter's whole identity is those three things; asked one at
a time from three menu entries, getting a new chapter to look right took
three visits. */
const addChapter = () => activeNotebook && setEditingChapter({});
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
@@ -737,37 +736,7 @@ export default function App() {
x: e.clientX,
y: e.clientY,
items: [
/* One entry, not two. The name and the colour are asked for together
when a chapter is made, so changing them is the same dialog again —
a second "Colour" submenu beside it offered half of what this does
and read as a different thing. */
{
label: "Rename or recolour",
icon: I.edit,
action: () =>
setNaming({
title: "Chapter",
label: "Chapter name",
value: chapter.name,
colors: CHAPTER_COLORS,
color: chapter.color ?? null,
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 }),
}),
},
{ label: "Edit", icon: I.edit, action: () => setEditingChapter(chapter) },
{ divider: true },
{
label: "Remove chapter",
@@ -861,11 +830,7 @@ export default function App() {
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}
@@ -982,9 +947,6 @@ export default function App() {
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
separators={separators}
onAddSeparator={addSeparator}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
/* Only in the fallback: there the list becomes the chapter, and
@@ -997,6 +959,7 @@ export default function App() {
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onReorder={rearrangeNotes}
onFileInto={fileIntoChapter}
listKey={`${activeFolder}:${listChapter ?? ""}`}
isCurrent={showsWhere ? !inChapter : null}
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
@@ -1197,16 +1160,23 @@ export default function App() {
)}
/>
)}
{naming && (
<NameDialog
title={naming.title}
value={naming.value}
label={naming.label ?? "Chapter name"}
colors={naming.colors}
color={naming.color}
confirmLabel={naming.confirmLabel ?? (naming.value ? "Save" : "Create")}
onSave={naming.onSave}
onClose={() => setNaming(null)}
{editingChapter && (
<ChapterDialog
chapter={editingChapter}
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon)
}
onClose={() => setEditingChapter(null)}
dark={dark}
/>
)}
{editingSeparator && (
<SeparatorDialog
separator={editingSeparator}
onSave={(changes) => editSeparator(editingSeparator.id, changes)}
onClose={() => setEditingSeparator(null)}
dark={dark}
/>
)}
+12 -2
View File
@@ -27,6 +27,9 @@
from the rows under it. */
--ne-th-bg: #eaeef2;
--ne-th-rule: #afb8c1;
/* One line of body text. Named because the task list needs to know it: a
checkbox is centred against a line, and a line is this tall. */
--ne-line: 1.7;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -64,7 +67,7 @@
an unset preference still reads as the serif it always was. */
font-family: var(--ne-font, "Newsreader", Georgia, serif);
font-size: 15px;
line-height: 1.7;
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
@@ -246,9 +249,16 @@
align-items: flex-start;
gap: 8px;
}
/* The box sits in the middle of the *first line* beside it, not at a fixed
nudge from the top. `margin-top: 0.35em` was measured once against one font at
one size; changing the note's typeface, or writing a task that wraps, left the
box floating above the text it belongs to. Giving the label the height of one
line and centring inside it holds for every size. */
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
display: flex;
align-items: center;
height: calc(1em * var(--ne-line));
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
+17 -3
View File
@@ -50,6 +50,15 @@ export const NoteLink = Node.create({
},
// Set when the target no longer exists, so a dead link reads as broken
// instead of silently going nowhere.
/* The target's own mark, kept on the link and refreshed like the label is.
* A reference that shows the same sheet-of-paper glyph whatever it points
* at tells you nothing; the mark you gave the note is how you recognise
* it everywhere else. */
icon: {
default: null,
parseHTML: (el) => el.getAttribute("data-icon") || null,
renderHTML: (a) => (a.icon ? { "data-icon": a.icon } : {}),
},
missing: {
default: false,
parseHTML: (el) => el.getAttribute("data-missing") === "true",
@@ -71,7 +80,7 @@ export const NoteLink = Node.create({
href: "#",
title: node.attrs.label,
}),
`${{ notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
`${node.attrs.icon || { notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`,
];
},
@@ -95,7 +104,12 @@ export const NoteLink = Node.create({
.insertContentAt(range, [
{
type: "noteLink",
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
attrs: {
noteId: props.id,
label: props.label,
kind: props.kind || "note",
icon: props.icon || null,
},
},
{ type: "text", text: " " },
])
@@ -131,7 +145,7 @@ function renderSuggestionList() {
it,
i
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
<span class="note-link-kind">${it.kind === "notebook" ? "📓" : "📝"}</span>
<span class="note-link-kind">${it.icon || (it.kind === "notebook" ? "📓" : "📝")}</span>
<span class="note-link-label"></span>
</button>`
)
+11 -4
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel }) {
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -139,8 +139,15 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
const label = resolveLinkLabel(node.attrs.noteId);
const missing = label === null;
const nextLabel = missing ? node.attrs.label : label;
if (nextLabel !== node.attrs.label || missing !== !!node.attrs.missing) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing }]);
// The mark travels the same way, and for the same reason: it belongs to
// the note, so it has to follow when the note's changes.
const icon = (resolveLinkIcon ? resolveLinkIcon(node.attrs.noteId) : node.attrs.icon) ?? null;
if (
nextLabel !== node.attrs.label ||
missing !== !!node.attrs.missing ||
icon !== (node.attrs.icon ?? null)
) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing, icon }]);
}
});
if (edits.length === 0) return;
@@ -149,7 +156,7 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
tr.setMeta(LABEL_REFRESH, true);
tr.setMeta("addToHistory", false); // undo should not step through renames
editor.view.dispatch(tr);
}, [editor, resolveLinkLabel]);
}, [editor, resolveLinkLabel, resolveLinkIcon]);
const handleClick = useCallback(
(e) => {
+54 -42
View File
@@ -193,48 +193,60 @@ 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"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
}}
/>
{/* The mark and the name are one thing, so they sit in one group with a
small gap of their own. Left as separate children of the header they
were spaced like the buttons at the far end — the mark read as a
control that happened to be parked beside the title. */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flex: 1, minWidth: 0 }}>
<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",
/* One square box whatever it holds. An emoji and the drawn glyph
have different metrics, and sizing to the content moved the
title sideways the moment a mark was chosen. */
width: 26,
height: 26,
flexShrink: 0,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
padding: 0,
fontSize: 16,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
value={note.title}
onChange={(e) => onUpdate({ title: e.target.value })}
style={{
flex: 1,
border: "none",
background: "transparent",
outline: "none",
fontSize: isMobile ? 15 : 17,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
minWidth: 0,
// Matches the box beside it, so the two sit on one line rather
// than each finding its own centre.
lineHeight: "26px",
padding: 0,
}}
/>
</div>
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
{I.export}
</button>
@@ -37,6 +37,7 @@ export function NoteEditor({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
backlinks,
}) {
const [mode, setMode] = useState("text");
@@ -864,6 +865,7 @@ export function NoteEditor({
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
+45 -148
View File
@@ -5,6 +5,7 @@ import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
import { useReorderDrag } from "../../hooks/useReorderDrag";
import { Rule } from "../../ui/separatorStyle.jsx";
const iconButton = (T) => ({
display: "flex",
@@ -76,66 +77,23 @@ 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.
*
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
* Draggable like a note, because a divider that cannot move with what it divides
* is worse than none.
*
* Both renaming and removing are right-click, which is how everything else in
* this list is acted on. A left-click on the label started renaming, so the row
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
* Everything about it — its name, its colour, the shape of its rule — is changed
* in one dialog reached by right-click, like every other row here is acted on.
* It used to be three menu entries and an input that appeared in the row itself:
* three shapes of interaction for one small thing, and none of them showing what
* the result would look like.
*/
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
function SeparatorRow({ T, separator, onContextMenu, grip }) {
// 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);
}, [editing, separator.label]);
const commit = () => {
onStartRename(null);
if (draft !== separator.label) onRename(separator.id, draft);
};
return (
<div
data-separator={separator.id}
@@ -155,73 +113,27 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
{/* 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. */}
kept the row from jumping when renaming began — which it no longer can,
since renaming happens in a dialog — at the cost of a short name
floating in the middle of a gap it did not fill. */}
<div
title="Right-click to edit"
style={{
flex: "0 1 auto",
minWidth: MIDDLE_MIN,
maxWidth: MIDDLE_MAX,
display: "flex",
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: separator.label ? separator.color || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(separator.label);
onStartRename(null);
}
}}
aria-label="Separator label"
placeholder="Section"
// Follows what is being typed, so the row grows with the name
// instead of standing in a slot sized for a name nobody wrote.
size={Math.max(6, draft.length + 1)}
style={{
width: "100%",
minWidth: 0,
border: `1px solid ${T.border}`,
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textAlign: "center",
// Matches the button's box exactly, so nothing shifts vertically
// either when the input replaces it.
padding: "1px 4px",
outline: "none",
}}
/>
) : (
<div
title="Right-click for options"
style={{
width: "100%",
border: "1px solid transparent",
background: "transparent",
color: separator.label ? separator.color || T.text : T.muted,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Unnamed"}
</div>
)}
{separator.label || "Unnamed"}
</div>
<Rule colour={colour} style={separator.style} />
</div>
@@ -308,11 +220,7 @@ export function ChapterNotes({
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
@@ -332,9 +240,6 @@ export function ChapterNotes({
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
@@ -388,28 +293,10 @@ export function ChapterNotes({
</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>
)}
{/* No button to add one here. A line is drawn in the notebook and dragged
into the chapter, the same way a note is put in one — a second way to
make the same thing, in a second place, is a second thing to keep
straight. */}
</>
);
}
@@ -633,9 +520,6 @@ export function NoteList({
reorderable,
separators,
onAddSeparator,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
chapters,
openChapter,
@@ -645,6 +529,8 @@ export function NoteList({
expandedChapter,
renderChapterNotes,
onReorder,
/** Drops a carried row *into* a chapter rather than beside it. */
onFileInto,
/** 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. */
@@ -674,7 +560,7 @@ export function NoteList({
/* 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(moving, onReorder);
const dnd = useReorderDrag(moving, onReorder, onFileInto);
/* 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. */
@@ -968,8 +854,22 @@ export function NoteList({
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
const taking = dnd.intoId === entry.id;
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id, true) : {})}
data-chapter-drop={taking ? "on" : undefined}
style={{
opacity: lifted ? 0.45 : 1,
/* Held over, the whole row lights up rather than growing a
line above it: a chapter is about to take what is
carried, not stand next to it. */
outline: taking ? `2px solid ${T.accent}` : "none",
outlineOffset: -1,
borderRadius: 10,
}}
>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
@@ -994,9 +894,6 @@ export function NoteList({
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
/>
</div>
@@ -27,6 +27,7 @@ export function PdfWorkspace({
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
resolveLinkIcon,
}) {
const [showNotes, setShowNotes] = useState(false);
// Set when a link in the notes points at a marker; the viewer clears it once
@@ -155,6 +156,7 @@ export function PdfWorkspace({
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
resolveLinkLabel={resolveLabel}
resolveLinkIcon={resolveLinkIcon}
/* On a phone the viewer is not on screen at all, so the way back to
the document has to be here. */
onBackToDocument={narrow ? () => setShowNotes(false) : null}
+8 -3
View File
@@ -54,7 +54,12 @@ export interface NoteData {
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
createChapter: (notebookId: string, name: string, color?: string | null) => Promise<Chapter>;
createChapter: (
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
@@ -364,8 +369,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color);
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
setChapters((p) => [...p, chapter]);
return chapter;
},
+44 -9
View File
@@ -23,31 +23,50 @@ const MOUSE_THRESHOLD = 5;
export interface ReorderDrag {
/** The row the pointer is over, for the drop indicator. */
overId: string | null;
/** A row that would *take* what is being carried, rather than sit next to it. */
intoId: string | null;
/** The row being carried, so it can be shown as lifted. */
draggingId: string | null;
/** Spread onto each row; `id` is what gets reordered. */
rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void };
/**
* Spread onto each row; `id` is what gets reordered.
*
* `into` marks a row that is a place rather than a neighbour — a chapter.
* Dropping on one files what is carried into it instead of putting it above.
*/
rowProps: (
id: string,
into?: boolean
) => {
"data-reorder-id": string;
"data-reorder-into"?: string;
onPointerDown: (e: React.PointerEvent) => void;
};
/** Spread onto the grip inside a row. */
gripProps: { "data-reorder-grip": string };
}
export function useReorderDrag(
enabled: boolean,
reorder: (dragId: string, targetId: string) => void
reorder: (dragId: string, targetId: string) => void,
fileInto?: (dragId: string, targetId: string) => void
): ReorderDrag {
const [overId, setOverId] = useState<string | null>(null);
const [intoId, setIntoId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
// Everything the pointer handlers need, kept out of state: they run far more
// often than anything needs to be redrawn.
const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null);
const overRef = useRef<string | null>(null);
const intoRef = useRef<string | null>(null);
const stop = useCallback(() => {
drag.current = null;
overRef.current = null;
intoRef.current = null;
setDraggingId(null);
setOverId(null);
setIntoId(null);
document.body.style.removeProperty("user-select");
}, []);
@@ -87,9 +106,18 @@ export function useReorderDrag(
const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
const target = row?.dataset.reorderId ?? null;
const next = target && target !== d.id ? target : null;
if (next !== overRef.current) {
overRef.current = next;
setOverId(next);
/* A chapter takes what is dropped on it rather than standing beside it,
* which is what "drag it into the chapter" means. Filing is only offered
* when there is somewhere to file *to*. */
const into = next && row?.dataset.reorderInto !== undefined && fileInto ? next : null;
if (into !== intoRef.current) {
intoRef.current = into;
setIntoId(into);
}
const beside = into ? null : next;
if (beside !== overRef.current) {
overRef.current = beside;
setOverId(beside);
}
if (e.cancelable) e.preventDefault();
};
@@ -97,7 +125,9 @@ export function useReorderDrag(
const up = () => {
const d = drag.current;
const target = overRef.current;
if (d?.active && target && target !== d.id) reorder(d.id, target);
const into = intoRef.current;
if (d?.active && into && into !== d.id) fileInto?.(d.id, into);
else if (d?.active && target && target !== d.id) reorder(d.id, target);
stop();
};
@@ -109,7 +139,7 @@ export function useReorderDrag(
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", stop);
};
}, [enabled, reorder, stop]);
}, [enabled, reorder, fileInto, stop]);
// A drag that outlives its list would leave the page unselectable.
useEffect(
@@ -121,8 +151,13 @@ export function useReorderDrag(
return {
overId,
intoId,
draggingId,
rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }),
rowProps: (id: string, into = false) => ({
"data-reorder-id": id,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
gripProps: { "data-reorder-grip": "" },
};
}
+12 -6
View File
@@ -545,7 +545,10 @@ type QueuedAction =
};
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string; color?: string | null } }
| {
action: "createChapter";
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
}
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
@@ -663,6 +666,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
notebookId: d.notebookId,
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
});
if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
@@ -672,6 +676,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
notebookId: d.notebookId,
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0,
});
// Notes filed into it while it was local, and anything still queued
@@ -837,13 +842,14 @@ export async function fetchChapters(): Promise<Chapter[]> {
export async function createChapter(
notebookId: string,
name: string,
color: string | null = null
color: string | null = null,
icon: string | null = null
): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color });
const chapter: Chapter = { id: r.id, notebookId, name, color, sortOrder };
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
await db.putChapter(chapter);
notifyData("chapter", chapter);
return chapter;
@@ -852,9 +858,9 @@ export async function createChapter(
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, sortOrder, _pendingCreate: true };
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Everything about one chapter, in one place — and the same place whether it is
* being made or changed.
*
* It used to be a name asked on creation and then two or three entries in the
* chapter's own menu for everything else, so making a chapter that looked the
* way you wanted took three visits. A chapter's whole identity is a name, a mark
* and a colour; they are one decision and belong in one dialog.
*
* The row is drawn at the top exactly as the list will draw it, because the
* point of a mark and a colour is how the row looks, and a grid of swatches does
* not answer that.
*/
export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const existing = !!chapter?.id;
const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null);
const inputRef = useRef(null);
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]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour });
onClose();
};
return (
<div
data-chapter-dialog
{...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 14px", fontSize: 16, fontWeight: 700, color: T.text }}>
{existing ? "Chapter" : "New chapter"}
</h3>
{/* The row as the list will draw it, changing as the choices change. */}
<div
data-chapter-preview
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "10px 12px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<span
style={{
display: "flex",
alignItems: "center",
color: colour || T.accent,
flexShrink: 0,
fontSize: icon ? 15 : undefined,
lineHeight: 1,
}}
>
{icon || I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name || "Unnamed chapter"}
</span>
</div>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Chapter name"
placeholder="Chapter name"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(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 style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
<IconChooser T={T} value={icon} onPick={setIcon} height={170} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<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-chapter-save
onClick={save}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: ready ? T.accent : T.border,
color: "#fff",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{existing ? "Save" : "Create"}
</button>
</div>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useMemo, useState } from "react";
/**
* The marks a note or a chapter can wear, and the grid for choosing one.
*
* 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 make it usable: nobody hunts a pictogram grid by shape.
* They are also why the list is written out rather than generated.
*
* One component, used by both dialogs that offer a mark. Two grids would drift.
*/
export 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" },
];
export function IconChooser({ T, value, onPick, height = 200 }) {
const [query, setQuery] = useState("");
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
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(40px, 1fr))",
gap: 6,
marginTop: 10,
maxHeight: height,
overflowY: "auto",
}}
>
{/* "None" belongs in the grid rather than off in a corner: taking a mark
off is the same kind of choice as putting one on. */}
<button
type="button"
data-icon-option="none"
title="No icon"
aria-label="No icon"
onClick={() => onPick(null)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 13,
lineHeight: 1,
color: T.muted,
border: !value ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: !value ? `${T.accent}22` : "transparent",
}}
>
—
</button>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => onPick(icon.char)}
style={{
height: 40,
borderRadius: 10,
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: value === 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>
</>
);
}
-181
View File
@@ -1,181 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Asking for one name.
*
* A real dialog rather than `window.prompt`, for the same reasons the link
* dialog is one: the browser's box cannot be styled, cannot be reached by the
* tests, and on a phone it arrives as a system alert with the app greyed out
* behind it.
*
* Deliberately small. Naming a chapter is one field and two buttons, and
* anything more would be a form.
*
* The one exception is the colour, offered when `colors` is given: it belongs to
* the same act. Making a chapter and then having to right-click it to say which
* one it is meant two steps for one decision.
*/
export function NameDialog({
title,
value = "",
label = "Name",
confirmLabel = "Save",
colors,
color: initialColor = null,
onSave,
onClose,
dark,
}) {
const [name, setName] = useState(value);
const [color, setColor] = useState(initialColor);
const backdrop = useBackdropClose(onClose);
const inputRef = useRef(null);
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]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave(name.trim(), color);
onClose();
};
return (
<div
data-name-dialog
{...backdrop}
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: 380,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 22,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
aria-label={label}
placeholder={label}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
style={{
width: "100%",
padding: "10px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 14,
outline: "none",
boxSizing: "border-box",
}}
/>
{colors?.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, marginBottom: 8 }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{[{ label: "None", value: null }, ...colors].map((c) => {
const picked = (c.value ?? null) === (color ?? null);
return (
<button
key={c.label}
type="button"
onClick={() => setColor(c.value ?? null)}
title={c.label}
aria-label={c.label}
aria-pressed={picked}
data-colour-option={c.value ?? "none"}
style={{
width: 28,
height: 28,
borderRadius: 9,
cursor: "pointer",
// The ring, not a tick: a tick over a dark swatch is
// unreadable and over a light one it is the only thing seen.
border: picked ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 11,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<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
onClick={save}
disabled={!ready}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: ready ? T.accent : T.border,
color: "#fff",
cursor: ready ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS, SEPARATOR_STYLES } from "./constants.jsx";
import { Rule } from "./separatorStyle.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Everything about one line, in one place.
*
* A separator used to be edited from three entries in its own menu — rename,
* colour, line — each a different shape of interaction, and none of them showing
* what the result would look like. Choosing "dotted" from a list of words is a
* guess; the difference between dotted and dashed at this size is not something
* a word settles.
*
* So the dialog draws the thing itself, twice over: once at the top as it will
* appear in the list, and once beside each choice of line.
*/
export function SeparatorDialog({ separator, onSave, onClose, dark }) {
const [label, setLabel] = useState(separator.label || "");
const [colour, setColour] = useState(separator.color ?? null);
const [style, setStyle] = useState(separator.style || "solid");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
faint: dark ? "#484f58" : "#afb8c1",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
// What the list will actually draw: no colour means the list's own faint grey.
const drawn = colour || T.faint;
const save = () => {
onSave({ label: label.trim(), color: colour, style });
onClose();
};
return (
<div
data-separator-dialog
{...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: 400,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>Separator</h3>
{/* The line as the list will draw it, changing as the choices change. */}
<div
data-separator-preview
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "10px 8px",
borderRadius: 10,
background: T.field,
marginBottom: 16,
}}
>
<Rule colour={drawn} style={style} />
<div
style={{
flex: "0 1 auto",
maxWidth: 170,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
color: label ? colour || T.text : T.muted,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 54,
}}
>
{label || "Unnamed"}
</div>
<Rule colour={drawn} style={style} />
</div>
<input
ref={inputRef}
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") onClose();
}}
aria-label="Separator name"
placeholder="Name — may be left blank"
style={{
width: "100%",
padding: "10px 13px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13.5,
outline: "none",
boxSizing: "border-box",
}}
/>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Line</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{SEPARATOR_STYLES.map((s) => (
<button
key={s.value}
type="button"
data-line-option={s.value}
aria-pressed={style === s.value}
onClick={() => setStyle(s.value)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 10px",
borderRadius: 9,
cursor: "pointer",
border: style === s.value ? `1px solid ${T.accent}` : `1px solid transparent`,
background: style === s.value ? `${T.accent}18` : "transparent",
color: style === s.value ? T.text : T.muted,
fontSize: 12,
fontWeight: 600,
textAlign: "left",
}}
>
<span style={{ width: 62, flexShrink: 0 }}>{s.label}</span>
{/* Drawn with the very rule the list uses, in the colour chosen —
a sample that could disagree with the result is worse than none. */}
<span style={{ display: "flex", alignItems: "center", flex: 1 }}>
<Rule colour={drawn} style={s.value} />
</span>
</button>
))}
</div>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (colour ?? null);
return (
<button
key={c.label}
type="button"
data-colour-option={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setColour(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 style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
<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-separator-save
onClick={save}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* How a separator's rule is drawn.
*
* Its own module because two places draw it and they must not drift: the list,
* where the line actually lives, and the dialog that offers the choice. A
* preview built from a second copy of these rules would eventually show
* something the list does not draw, which is worse than no preview at all.
*
* 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.
*/
export 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. */
export function Rule({ colour, style, width }) {
const shape = RULE_STYLES[style] || RULE_STYLES.solid;
return (
<div
data-separator-rule={style || "solid"}
style={{
flex: width ? undefined : 1,
width,
minWidth: width ? undefined : 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid ? { background: colour } : { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}