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

+10
View File
@@ -7,6 +7,7 @@ import { useResponsive } from "./hooks/useResponsive";
import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { useEdgeSwipe } from "./hooks/useEdgeSwipe";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
@@ -165,6 +166,15 @@ export default function App() {
const T = useMemo(() => makeTheme(dark), [dark]);
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
/* Dragging in from the left edge shows the sidebar, dragging it back hides it
— only where it is an overlay, since on a desktop it is always there. */
useEdgeSwipe({
enabled: isMobile,
isOpen: sidebarOpen,
onOpen: useCallback(() => setSidebarOpen(true), [setSidebarOpen]),
onClose: useCallback(() => setSidebarOpen(false), [setSidebarOpen]),
});
const { canInstall, reason: installReason, promptInstall } = usePwaInstall();
// Presentation only, and it lives on the account so it follows to the phone.
+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>
+16
View File
@@ -24,6 +24,22 @@ describe("text and marks", () => {
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
});
it("carries a highlight as <mark>, with its colour when it has one", () => {
expect(md(doc(para(text("gelb", [{ type: "highlight", attrs: { color: "#fff3a3" } }]))))).toBe(
'<mark style="background-color: #fff3a3">gelb</mark>'
);
// A marker without a colour is still a marker, and that is what most other
// writers emit.
expect(md(doc(para(text("markiert", [{ type: "highlight" }]))))).toBe("<mark>markiert</mark>");
});
it("carries a text colour as a span, and nothing at all without one", () => {
expect(md(doc(para(text("rot", [{ type: "textColor", attrs: { color: "#d1242f" } }]))))).toBe(
'<span style="color: #d1242f">rot</span>'
);
expect(md(doc(para(text("schlicht", [{ type: "textColor" }]))))).toBe("schlicht");
});
it("keeps a web link", () => {
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
expect(out).toBe("[hier](https://example.com)");
+11
View File
@@ -87,6 +87,17 @@ function inlineNode(n: DocNode, o: MarkdownOptions): string {
// Markdown has no underline; HTML is the least surprising stand-in.
out = `<u>${out}</u>`;
break;
case "highlight":
/* Markdown has no colour either. `<mark>` is the element that means
* "highlighted", so a reader that drops the colour still shows the word
* as marked rather than losing the emphasis altogether. */
out = mark.attrs?.color
? `<mark style="background-color: ${mark.attrs.color}">${out}</mark>`
: `<mark>${out}</mark>`;
break;
case "textColor":
out = mark.attrs?.color ? `<span style="color: ${mark.attrs.color}">${out}</span>` : out;
break;
case "link":
out = `[${out}](${mark.attrs?.href || ""})`;
break;
+8
View File
@@ -42,6 +42,10 @@ function inline(nodes: DocNode[] | undefined, resolveImage: (id: string) => stri
else if (mark.type === "underline") html = `<u>${html}</u>`;
else if (mark.type === "strike") html = `<s>${html}</s>`;
else if (mark.type === "code") html = `<code>${html}</code>`;
else if (mark.type === "highlight")
html = `<mark${mark.attrs?.color ? ` style="background-color: ${esc(mark.attrs.color)}"` : ""}>${html}</mark>`;
else if (mark.type === "textColor" && mark.attrs?.color)
html = `<span style="color: ${esc(mark.attrs.color)}">${html}</span>`;
else if (mark.type === "link") html = `<a href="${esc(mark.attrs?.href)}">${html}</a>`;
}
return html;
@@ -136,6 +140,10 @@ const STYLES = `
pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere;
white-space: pre-wrap; font-size: 9.5pt; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; }
/* Dark ink on a highlight whatever the page colour, the way a marker leaves
the writing alone — and printed at all, which browsers skip by default. */
mark { background-color: #fff3a3; color: #111; padding: 0 1px;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table { border-collapse: collapse; width: 100%; margin: 0 0 3mm; page-break-inside: avoid; }
th, td { border: 1px solid #bbb; padding: 1.6mm 2.2mm; text-align: left; vertical-align: top; }
th { background: #f2f2f2; font-weight: 600; }
+114 -88
View File
@@ -7,6 +7,17 @@ import { FONT_SERIF, FONT_UI } from "../../ui/theme";
const RECENT_COUNT = 6;
/** Landing view: every notebook as a cover, plus the notes touched most recently. */
/**
* The six most recently edited, whatever order the list arrives in.
*
* `activeNotes` carries whichever arrangement the notebook it came from was in —
* a hand-made order, or a title sort — and under this heading that is simply the
* wrong list: "Recent" is a question about time, and it must answer it even when
* every notebook is arranged by hand.
*/
const byLastEdited = (notes, count) =>
[...notes].sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))).slice(0, count);
export function Dashboard({
T,
isMobile,
@@ -20,6 +31,7 @@ export function Dashboard({
onNotebookContextMenu,
dnd,
}) {
const recentlyEdited = byLastEdited(activeNotes, RECENT_COUNT);
return (
<div
style={{
@@ -66,11 +78,112 @@ export function Dashboard({
fontFamily: FONT_SERIF,
}}
>
My Notebooks
Dashboard
</h1>
</div>
</div>
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
<h2
style={{
fontSize: 16,
fontWeight: 600,
color: T.text,
marginBottom: 14,
fontFamily: FONT_SERIF,
}}
>
Recent Notes
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
gap: 12,
}}
>
{recentlyEdited.map((note) => {
const nb = notebooks.find((b) => b.id === note.folderId);
return (
<button
key={note.id}
onClick={() => onOpenNote(note)}
style={{
display: "flex",
flexDirection: "column",
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.card,
cursor: "pointer",
textAlign: "left",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
>
{nb && (
<div
style={{
width: 14,
height: 10,
borderRadius: 2,
background: coverBackground(nb.cover, nb.coverPattern),
flexShrink: 0,
}}
/>
)}
<span
style={{
fontSize: 13,
fontWeight: 600,
color: T.text,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title}
</span>
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
{nb && (
<span
style={{
fontSize: 10,
color: T.faint,
background: T.accentSoft,
padding: "1px 6px",
borderRadius: 6,
}}
>
{nb.name}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
{/* The covers come second now. What you were last writing is what you
usually came back for; the shelf is for choosing where to go next. */}
<h2
style={{
fontSize: 16,
fontWeight: 600,
color: T.text,
margin: 0,
padding: isMobile ? "0 16px 14px" : "0 36px 14px",
fontFamily: FONT_SERIF,
}}
>
My Notebooks
</h2>
<div
style={{
padding: isMobile ? "0 16px 24px" : "0 36px 32px",
@@ -142,93 +255,6 @@ export function Dashboard({
</div>
</button>
</div>
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
<h2
style={{
fontSize: 16,
fontWeight: 600,
color: T.text,
marginBottom: 14,
fontFamily: FONT_SERIF,
}}
>
Recent Notes
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
gap: 12,
}}
>
{activeNotes.slice(0, RECENT_COUNT).map((note) => {
const nb = notebooks.find((b) => b.id === note.folderId);
return (
<button
key={note.id}
onClick={() => onOpenNote(note)}
style={{
display: "flex",
flexDirection: "column",
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.card,
cursor: "pointer",
textAlign: "left",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
>
{nb && (
<div
style={{
width: 14,
height: 10,
borderRadius: 2,
background: coverBackground(nb.cover, nb.coverPattern),
flexShrink: 0,
}}
/>
)}
<span
style={{
fontSize: 13,
fontWeight: 600,
color: T.text,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title}
</span>
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
{nb && (
<span
style={{
fontSize: 10,
color: T.faint,
background: T.accentSoft,
padding: "1px 6px",
borderRadius: 6,
}}
>
{nb.name}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
+6 -2
View File
@@ -828,9 +828,13 @@ export function NoteEditor({
height: stageSize.h,
pointerEvents: "none",
background: spare ? "transparent" : T.bg,
border: isMobileViewport && !spare ? "none" : `1px dashed ${T.border}`,
/* On a phone the sheet used to be drawn with no edge at all:
full width, no border, no shadow — which left nothing to
see where one page ended and the next began. The paper
still runs to the edges there, but it has an outline, so
the division is visible. */
borderStyle: spare ? "dashed" : "solid",
borderWidth: isMobileViewport && !spare ? 0 : 1,
borderWidth: 1,
borderColor: T.border,
borderRadius: isMobileViewport && !spare ? 0 : 4,
boxShadow: isMobileViewport || spare ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
+106 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PdfViewer } from "./PdfViewer.jsx";
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
@@ -7,6 +7,18 @@ import { allPinnedNotes, asPdfStrokeDoc } from "./pageStrokes";
const MOBILE_BREAKPOINT = 900;
/** Four corners pushed out, and the same four pulled back in. */
const ExpandIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
</svg>
);
const ShrinkIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" />
</svg>
);
/**
* A PDF and the notes taken beside it.
*
@@ -30,6 +42,8 @@ export function PdfWorkspace({
resolveLinkIcon,
}) {
const [showNotes, setShowNotes] = useState(false);
const [full, setFull] = useState(false);
const rootRef = useRef(null);
// Set when a link in the notes points at a marker; the viewer clears it once
// it has scrolled there.
const [focusPin, setFocusPin] = useState(null);
@@ -45,8 +59,52 @@ export function PdfWorkspace({
border: dark ? "#30363d" : "#d0d7de",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
// The same paper-coloured ground the viewer lays its pages on, so filling
// the screen does not put a strip of app colour around the document.
bg: dark ? "#0d1117" : "#f0f2f5",
};
/* ── the document, and nothing else ──
*
* Two things at once, because either alone is half a job. The browser's own
* full screen puts the document past the address bar and the window frame,
* which is the point of asking on a laptop or a tablet; and the app is laid
* out as an overlay over itself, which is what actually removes the sidebar,
* the note list and the title bar. A browser that refuses the first — an
* iPhone does — still gets the second.
*/
const enterFull = () => {
setFull(true);
rootRef.current?.requestFullscreen?.().catch(() => {
/* refused or unavailable: the overlay is the whole feature anyway */
});
};
const leaveFull = useCallback(() => {
setFull(false);
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}, []);
/* Leaving the browser's full screen — with its own Escape, with F11, by any
route — must take the overlay with it, or the app would be left pretending
to be full screen inside an ordinary window. */
useEffect(() => {
const onChange = () => {
if (!document.fullscreenElement) setFull(false);
};
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
/* And Escape works even where there was never a browser full screen to leave. */
useEffect(() => {
if (!full) return undefined;
const onKey = (e) => {
if (e.key === "Escape") leaveFull();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [full, leaveFull]);
// On a narrow screen the two are alternatives, not neighbours.
const showPdf = !narrow || !showNotes;
@@ -99,6 +157,22 @@ export function PdfWorkspace({
[narrow, onOpenNote]
);
const barButton = (on) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
height: 28,
padding: "0 10px",
borderRadius: 8,
border: "none",
background: on ? T.accent + "22" : "transparent",
color: on ? T.accent : T.muted,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
});
const notesToggle = (
<button
data-pdf-toggle-notes={showNotes ? "on" : "off"}
@@ -124,15 +198,44 @@ export function PdfWorkspace({
</button>
);
/* Both controls in the viewer's own toolbar, beside the zoom — a second row
for two buttons would cost a strip of reading height on every screen. */
const toolbarExtras = (
<>
<button
data-pdf-fullscreen={full ? "on" : "off"}
onClick={() => (full ? leaveFull() : enterFull())}
title={full ? "Leave full screen (Esc)" : "Full screen"}
style={barButton(full)}
>
{full ? <ShrinkIcon /> : <ExpandIcon />}
</button>
{notesToggle}
</>
);
return (
<div style={{ flex: 1, display: "flex", minHeight: 0, minWidth: 0 }}>
<div
ref={rootRef}
data-pdf-workspace
style={{
flex: 1,
display: "flex",
minHeight: 0,
minWidth: 0,
...(full
? { position: "fixed", inset: 0, zIndex: 3000, background: T.bg }
: null),
}}
>
{showPdf && (
<div style={{ flex: 1, display: "flex", minWidth: 0, minHeight: 0 }}>
<PdfViewer
note={note}
dark={dark}
onUpdate={onUpdate}
notesToggle={notesToggle}
notesToggle={toolbarExtras}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
+90
View File
@@ -0,0 +1,90 @@
import { useEffect } from "react";
/** How close to the left edge a swipe must start to count as opening one. */
const EDGE_PX = 28;
/** How far it must travel sideways before it is a swipe rather than a tap. */
const TRAVEL_PX = 60;
/** And how straight: past this much vertical movement it is a scroll. */
const SLOPE = 1.2;
interface EdgeSwipe {
enabled: boolean;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
/**
* Dragging in from the left edge shows the sidebar; dragging it back hides it.
*
* The gesture every app on a phone has, and the reason it has to be a gesture
* here too: the button that opens the sidebar sits in the top corner, which is
* the one part of a tall screen a thumb cannot reach.
*
* Three rules keep it from firing by accident, and each is there for something
* that went wrong without it:
*
* • it must **start at the edge**, or every sideways drag inside a note — a
* text selection, a table being scrolled — would open the sidebar;
* • it must travel far enough to be meant, or a tap that slides by two pixels
* counts;
* • it must be more sideways than downwards, or scrolling a long note with a
* thumb near the edge opens it constantly.
*
* Listening on `document` rather than on the sidebar: the gesture starts where
* the sidebar is *not*, which is the whole point of it.
*/
export function useEdgeSwipe({ enabled, isOpen, onOpen, onClose }: EdgeSwipe): void {
useEffect(() => {
if (!enabled) return undefined;
let startX = 0;
let startY = 0;
let tracking = false;
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return; // a pinch is not a swipe
const t = e.touches[0];
startX = t.clientX;
startY = t.clientY;
// Closed: only from the edge. Open: anywhere on the panel, because the
// whole panel is the thing being pushed back.
tracking = isOpen ? startX < window.innerWidth * 0.8 : startX <= EDGE_PX;
};
const onMove = (e: TouchEvent) => {
if (!tracking || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (Math.abs(dy) > Math.abs(dx) * SLOPE) {
tracking = false; // a scroll, not a swipe
return;
}
if (!isOpen && dx > TRAVEL_PX) {
tracking = false;
onOpen();
} else if (isOpen && dx < -TRAVEL_PX) {
tracking = false;
onClose();
}
};
const stop = () => {
tracking = false;
};
// Passive: this never calls preventDefault — the page must keep scrolling
// normally while the gesture is being judged.
document.addEventListener("touchstart", onStart, { passive: true });
document.addEventListener("touchmove", onMove, { passive: true });
document.addEventListener("touchend", stop, { passive: true });
document.addEventListener("touchcancel", stop, { passive: true });
return () => {
document.removeEventListener("touchstart", onStart);
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", stop);
document.removeEventListener("touchcancel", stop);
};
}, [enabled, isOpen, onOpen, onClose]);
}
@@ -59,6 +59,51 @@ describe("blocks", () => {
});
});
describe("colour, which Markdown can only carry as HTML", () => {
const marksOf = (md: string) => first(md).content![0]!.marks!;
it("reads a highlight back, with and without a colour", () => {
expect(marksOf('<mark style="background-color: #fff3a3">gelb</mark>')).toMatchObject([
{ type: "highlight", attrs: { color: "#fff3a3" } },
]);
expect(marksOf("<mark>markiert</mark>")[0]!.type).toBe("highlight");
});
it("reads a text colour back", () => {
expect(marksOf('<span style="color: #d1242f">rot</span>')).toMatchObject([
{ type: "textColor", attrs: { color: "#d1242f" } },
]);
});
it("leaves a span that says nothing about colour alone", () => {
// Every other span in a pasted document would otherwise become a mark with
// nothing in it.
expect(plain(first('<span class="x">schlicht</span>'))).toContain("schlicht");
expect(first('<span class="x">schlicht</span>').content![0]!.marks ?? []).toEqual([]);
});
it("survives the round trip", () => {
const there = (d: DocNode) => markdownToNote(noteToMarkdown(d)).doc;
const source: DocNode = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "gelb", marks: [{ type: "highlight", attrs: { color: "#fff3a3" } }] } as DocNode,
{ type: "text", text: " und " } as DocNode,
{ type: "text", text: "rot", marks: [{ type: "textColor", attrs: { color: "#d1242f" } }] } as DocNode,
],
},
],
};
const back = there(source);
const spans = back.content![0]!.content!;
expect(spans[0]!.marks).toMatchObject([{ type: "highlight", attrs: { color: "#fff3a3" } }]);
expect(spans[spans.length - 1]!.marks).toMatchObject([{ type: "textColor", attrs: { color: "#d1242f" } }]);
});
});
describe("lists", () => {
it("reads a bullet list", () => {
const list = first("- eins\n- zwei");
+25
View File
@@ -130,6 +130,31 @@ export function inlineNodes(src: string, marks: Mark[] = [], images?: string[]):
i += u[0].length;
continue;
}
/* The two colours, which Markdown can only carry as HTML — written by
* this exporter, and readable enough that somebody might write them by
* hand. The colour is optional: `<mark>` on its own is still a highlight,
* and that is what most other writers emit. */
const marker = /^<mark(?:\s[^>]*?)?>([\s\S]*?)<\/mark>/i.exec(src.slice(i));
if (marker) {
const colour = /background-color:\s*([^;"']+)/i.exec(marker[0]);
flush();
out.push(
...inlineNodes(marker[1]!, [...marks, { type: "highlight", attrs: { color: colour ? colour[1]!.trim() : null } }], images)
);
i += marker[0].length;
continue;
}
const coloured = /^<span\s[^>]*style=["'][^"']*\bcolor:\s*([^;"']+)[^"']*["'][^>]*>([\s\S]*?)<\/span>/i.exec(
src.slice(i)
);
if (coloured) {
flush();
out.push(
...inlineNodes(coloured[2]!, [...marks, { type: "textColor", attrs: { color: coloured[1]!.trim() } }], images)
);
i += coloured[0].length;
continue;
}
}
if (c === "!" || c === "[") {
+2
View File
@@ -152,6 +152,8 @@ export function Sidebar({
return (
<div
data-sidebar
data-sidebar-open={open ? "yes" : "no"}
style={{
position: isMobile ? "fixed" : "relative",
zIndex: isMobile ? 900 : 1,