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:
1 parent
c9148540a8
commit
9507761bce
23 files changed
+1091
-109
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 === "[") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user