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:
1 parent
59844046c4
commit
d68b0775c2
20 files changed
+1185
-594
No files matched your search
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user