Colour, a page you can see the edge of, and a document on its own

Five of the nine things on the list, and one finding that came out of them.

Colour: a marker pen and an ink colour, written as marks here rather than
installed — the schema of a note decides what can be stored in it forever. A
highlight is a <mark> so a reader that drops the colour still shows the word
as marked, and marked text is painted dark whatever the theme. Both travel as
HTML in Markdown, like underline, and both are read back.

The PDF fills the screen: the browser's own full screen for the window frame,
an overlay for the sidebar and the list, Escape and a visible button for the
way out.

The dashboard leads with the recent notes, sorted by time whatever order the
notebooks are in. A tap on the blank part of a page puts the caret at the
nearest position instead of nowhere. A drag from the left edge shows the
sidebar. The page has an edge on a phone, where it had none.

The finding: at the speed a robot types, a long note loses keystrokes between
the editor and the pagination measuring after each one — 87 of 220
paragraphs. Nothing is lost at 50 characters a second, so the page-count
check now pastes its bulk text rather than measuring typing throughput. And
the history suite asked for "the first note in the list" without waiting for
the note to reach the server; it asks by name now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-26 12:40:21 +02:00
1 parent c9148540a8
commit 9507761bce
23 files changed
+1091 -109

No files matched your search

+133
View File
@@ -53,10 +53,32 @@ const AtIcon = () => (
* Reads its active states straight off the editor, so the buttons reflect the
* cursor even when formatting changed by keyboard shortcut.
*/
/* What the marker pen offers, and what the letters can be.
*
* Highlights are pale enough for dark ink to sit on them — a marked word is
* painted dark whatever the theme, the way a real marker leaves the writing
* alone. The ink colours are the other way round: they must be legible on both
* papers, so nothing paler than the mid tones.
*/
const HIGHLIGHTS = ["#fff3a3", "#c6f6c2", "#bfe3ff", "#ffc9de", "#ffd8a8", "#e2d4ff"];
const INK_COLOURS = ["#d1242f", "#bc4c00", "#1a7f37", "#0969da", "#8250df", "#6e7781"];
/** Two overlapping drops: colour, without saying which of the two kinds. */
function ColourIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 2.5c1.8 2 3 3.6 3 5a3 3 0 0 1-6 0c0-1.4 1.2-3 3-5Z" />
<path d="M3 13.5h10" strokeLinecap="round" />
</svg>
);
}
export function EditorToolbar({ editor, T, icons, compact, dark }) {
const [, force] = useState(0);
const [showTable, setShowTable] = useState(false);
const [showMore, setShowMore] = useState(false);
const [showColour, setShowColour] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
// ProseMirror state changes do not go through React; subscribe so the active
@@ -81,6 +103,15 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
return () => document.removeEventListener("pointerdown", close);
}, [showTable]);
useEffect(() => {
if (!showColour) return undefined;
const close = (e) => {
if (!e.target.closest?.("[data-colour-menu]")) setShowColour(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [showColour]);
useEffect(() => {
if (!showMore) return;
const close = (e) => {
@@ -155,6 +186,108 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
>
{icons.underline}
</button>
{/* Colour, both kinds, behind one button.
Two buttons would be two panels for one question — "what colour should
this be" — and the answer is nearly always one or the other, never both
at once. The panel says which is which. */}
<div style={{ position: "relative" }} data-colour-menu>
<button
style={btn(editor.isActive("highlight") || editor.isActive("textColor") || showColour)}
title="Highlight and text colour"
data-toolbar-colour={showColour ? "on" : "off"}
onClick={() => setShowColour((v) => !v)}
>
<ColourIcon />
</button>
{showColour && (
<div
data-colour-panel
style={{
position: "absolute",
top: 36,
left: 0,
width: 212,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 10,
zIndex: 200,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
{[
["Highlight", HIGHLIGHTS, "highlight", (c) => editor.chain().focus().toggleHighlight(c).run(), () => editor.chain().focus().unsetHighlight().run()],
["Text", INK_COLOURS, "textColor", (c) => editor.chain().focus().toggleTextColor(c).run(), () => editor.chain().focus().unsetTextColor().run()],
].map(([label, palette, markName, apply, clear]) => (
<div key={label} style={{ marginBottom: label === "Highlight" ? 10 : 0 }}>
{/* Deliberately not shaped like the swatches: a heading built
like the things under it is a button that does nothing. */}
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
marginBottom: 6,
}}
>
{label}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{palette.map((c) => (
<button
key={c}
type="button"
data-colour-swatch={c}
title={c}
onClick={() => apply(c)}
style={{
width: 24,
height: 24,
borderRadius: 7,
cursor: "pointer",
border: editor.isActive(markName, { color: c })
? `2px solid ${T.active}`
: `1px solid ${T.border}`,
background: markName === "highlight" ? c : "transparent",
color: c,
fontWeight: 700,
fontSize: 13,
lineHeight: 1,
padding: 0,
}}
>
{markName === "highlight" ? "" : "A"}
</button>
))}
<button
type="button"
data-colour-clear={markName}
title={`No ${label.toLowerCase()}`}
onClick={() => clear()}
style={{
width: 24,
height: 24,
borderRadius: 7,
cursor: "pointer",
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
fontSize: 12,
lineHeight: 1,
padding: 0,
}}
>
—
</button>
</div>
</div>
))}
</div>
)}
</div>
{divider}
<button
style={btn(editor.isActive("bulletList"))}
+9
View File
@@ -136,6 +136,15 @@
.note-editor .ProseMirror blockquote > * + * {
margin-top: 0.6em;
}
/* A marker pen leaves the writing alone: dark ink on the colour whatever the
theme, or a light-grey word on pale yellow would be unreadable on the dark
one. An explicit text colour is an inline style and still wins over this. */
.note-editor .ProseMirror mark {
background-color: #fff3a3;
color: #1f2328;
padding: 0 1px;
border-radius: 2px;
}
.note-editor .ProseMirror code {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
+5
View File
@@ -13,6 +13,7 @@ import TableCell from "@tiptap/extension-table-cell";
import { NoteLink } from "./noteLink.js";
import { TextAlign } from "./textAlign.js";
import { Highlight, TextColor } from "./textMarks.js";
import { Pagination } from "./pagination.js";
import { AttachmentImage } from "./attachmentImage.js";
@@ -48,6 +49,10 @@ export function buildExtensions({
TableCell,
NoteLink.configure({ search }),
TextAlign.configure({ types: ["paragraph", "heading"] }),
// A marker pen and a colour for the letters. Marks rather than a node, so
// they nest with bold and italic like every other emphasis.
Highlight,
TextColor,
Pagination.configure({ getGeometry }),
];
}
+120
View File
@@ -0,0 +1,120 @@
import { Mark, mergeAttributes } from "@tiptap/core";
/*
* Two ways to colour a word: behind it, and on it.
*
* Written here rather than installed, for the same reason the alignment
* extension is: the schema of a note decides what can be stored in it forever,
* and that belongs in files this project owns. Both are a few dozen lines,
* because a mark with one attribute is a few dozen lines.
*
* Neither keeps a list of allowed colours in the schema. The palette is an
* interface question — the toolbar offers what it offers — while the document
* only has to be able to hold what was chosen, including a colour picked before
* the palette last changed.
*/
/** Anything that could be a CSS colour and nothing that could be a stylesheet. */
const cleanColour = (value) => {
const v = String(value ?? "").trim();
return /^(#[0-9a-fA-F]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/]+\)|[a-zA-Z]{3,20})$/.test(v) ? v : null;
};
/**
* A marker pen: colour behind the text.
*
* `<mark>` rather than a span with a background, because that is the element
* that means "highlighted" — it survives being pasted elsewhere, it is what a
* screen reader announces, and it still reads as marked in a reader that drops
* the colour.
*/
export const Highlight = Mark.create({
name: "highlight",
addAttributes() {
return {
color: {
default: null,
parseHTML: (el) => cleanColour(el.getAttribute("data-color") || el.style.backgroundColor),
renderHTML: (attrs) =>
attrs.color
? { "data-color": attrs.color, style: `background-color: ${attrs.color}` }
: {},
},
};
},
parseHTML() {
return [{ tag: "mark" }, { style: "background-color", getAttrs: (v) => (cleanColour(v) ? {} : false) }];
},
renderHTML({ HTMLAttributes }) {
return ["mark", mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
setHighlight:
(color) =>
({ commands }) =>
commands.setMark(this.name, { color: cleanColour(color) }),
unsetHighlight:
() =>
({ commands }) =>
commands.unsetMark(this.name),
/* Pressing the colour a word already wears takes it off again. Without
this the only way back is the eraser button, and the button that put it
there is the one people press. */
toggleHighlight:
(color) =>
({ editor, commands }) =>
editor.isActive(this.name, { color: cleanColour(color) })
? commands.unsetMark(this.name)
: commands.setMark(this.name, { color: cleanColour(color) }),
};
},
});
/** Colour of the letters themselves. */
export const TextColor = Mark.create({
name: "textColor",
addAttributes() {
return {
color: {
default: null,
parseHTML: (el) => cleanColour(el.style.color),
renderHTML: (attrs) => (attrs.color ? { style: `color: ${attrs.color}` } : {}),
},
};
},
parseHTML() {
// Only a span that actually carries a colour: every other span in a pasted
// document would otherwise become a mark with nothing in it.
return [{ tag: "span[style*=color]", getAttrs: (el) => (cleanColour(el.style.color) ? {} : false) }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
setTextColor:
(color) =>
({ commands }) =>
commands.setMark(this.name, { color: cleanColour(color) }),
unsetTextColor:
() =>
({ commands }) =>
commands.unsetMark(this.name),
toggleTextColor:
(color) =>
({ editor, commands }) =>
editor.isActive(this.name, { color: cleanColour(color) })
? commands.unsetMark(this.name)
: commands.setMark(this.name, { color: cleanColour(color) }),
};
},
});
+21 -2
View File
@@ -207,9 +207,27 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
e.preventDefault();
window.open(url, "_blank", "noopener");
}
return;
}
/* Tapping the blank part of the sheet starts writing there.
*
* The text itself is only as tall as what has been written, so most of a
* fresh page is *beside* the editable area rather than in it, and a tap
* down there landed on nothing at all: no caret, and on a phone no
* keyboard either. Which is precisely where somebody taps when they open
* a note to jot something down.
*
* The nearest position to where the finger went, not simply the end: on a
* page with several paragraphs, "nearest" is what the tap meant. Only the
* end when the point is past everything, which is the empty-page case. */
if (!editor || editor.isDestroyed) return;
if (e.target.closest(".ProseMirror")) return; // a real click in the text
const at = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
if (at) editor.chain().focus().setTextSelection(at.pos).run();
else editor.chain().focus("end").run();
},
[onOpenNote]
[editor, onOpenNote]
);
return (
@@ -218,7 +236,8 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
data-theme={dark ? "dark" : "light"}
data-font={font || "serif"}
onClick={handleClick}
style={{ minHeight }}
// The whole sheet is somewhere to write, so it says so under the pointer.
style={{ minHeight, cursor: "text" }}
>
<EditorContent editor={editor} />
</div>