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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user