Quotes that hold more than a sentence, and one list of marks
A quote may hold whole blocks, and they now have room: the rule that gives the writing its rhythm reaches only the editor's own children, so inside a quote a heading, a list and a closing line stood jammed together. Leaving as Markdown they were written on consecutive lines, which every other reader folds into one — the sentence after a quoted list became more of the last bullet. They are divided by a quoted blank line now, the same rule the top level has had since 11 August. The marks were two copies of one list, in two dialogs, and only one of them had ever been extended. One list, one grid, twice as many marks, divided under headings — and anything else can be typed into the search box, which on a phone is where the emoji keyboard lives. The changelog is a third of its length: one entry per day rather than one per image, since a round that repaired what an earlier round the same day broke reads as noise afterwards. Every digest is still listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0523f3ba4
commit
c86c63922a
14 files changed
+742
-887
No files matched your search
@@ -126,6 +126,16 @@
|
||||
padding-left: 12px;
|
||||
color: var(--ne-muted);
|
||||
}
|
||||
/* A quote may hold whole blocks — a heading, a list, a closing line — and inside
|
||||
it they need the same rhythm as outside. The rule at the top of this file
|
||||
reaches only the editor's own children, so everything in a quote stood jammed
|
||||
against its neighbour, with the items of the list further apart than the
|
||||
blocks around them. It comes after `p { margin: 0 }` and the headings on
|
||||
purpose: those are exactly as specific, so written earlier they would win.
|
||||
A paragraph inside a list item is more specific still and keeps its zero. */
|
||||
.note-editor .ProseMirror blockquote > * + * {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
.note-editor .ProseMirror code {
|
||||
background: var(--ne-subtle);
|
||||
border: 1px solid var(--ne-border);
|
||||
|
||||
@@ -102,6 +102,33 @@ describe("blocks", () => {
|
||||
expect(md(doc(quote))).toBe("> zitiert");
|
||||
});
|
||||
|
||||
it("divides the blocks inside a quote, so a list does not swallow what follows", () => {
|
||||
const quote = {
|
||||
type: "blockquote",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 3 }, content: [text("Quartalszahlen")] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para(text("Umsatz"))] },
|
||||
{ type: "listItem", content: [para(text("Gewinn"))] },
|
||||
],
|
||||
},
|
||||
para(text("Alles nach Plan.")),
|
||||
],
|
||||
} as DocNode;
|
||||
// The dividing line is quoted as well: an empty line would end the quote.
|
||||
expect(md(doc(quote))).toBe(
|
||||
"> ### Quartalszahlen\n>\n> - Umsatz\n> - Gewinn\n>\n> Alles nach Plan."
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes a quote inside a quote", () => {
|
||||
const inner = { type: "blockquote", content: [para(text("innen"))] } as DocNode;
|
||||
const outer = { type: "blockquote", content: [para(text("außen")), inner] } as DocNode;
|
||||
expect(md(doc(outer))).toBe("> außen\n>\n> > innen");
|
||||
});
|
||||
|
||||
it("fences a code block and keeps its language", () => {
|
||||
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
|
||||
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
|
||||
|
||||
@@ -129,11 +129,17 @@ function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
|
||||
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
|
||||
}
|
||||
|
||||
case "blockquote":
|
||||
return children(n, o)
|
||||
.join("\n")
|
||||
.split("\n")
|
||||
.map((l) => (l ? `> ${l}` : ">"));
|
||||
case "blockquote": {
|
||||
/* A quote may hold anything a note may hold — headings, lists, tables,
|
||||
* further quotes — and inside it the blocks need the same blank line
|
||||
* between them as they do outside. Written on consecutive lines, a
|
||||
* paragraph after a list is read as more of the last list item, and a
|
||||
* table loses its header. The blank line is quoted as well (`>` on its
|
||||
* own), because a truly empty line ends the quote instead of dividing it. */
|
||||
const lines = stack(n.content || [], o);
|
||||
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines.map((l) => (l ? `> ${l}` : ">"));
|
||||
}
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = n.attrs?.language || "";
|
||||
@@ -172,6 +178,26 @@ function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
|
||||
return (n.content || []).flatMap((c) => block(c, o, indent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks one after another, with a blank line between them.
|
||||
*
|
||||
* Without one, Markdown runs adjacent blocks together: a bullet list written
|
||||
* straight after another list becomes more items of the first, and a quote or a
|
||||
* code fence on the line after a list item is read as part of that item. That is
|
||||
* not a quirk of one reader — it is what the format says, so the file rendered
|
||||
* wrongly everywhere, and reading it back in produced a document that did not
|
||||
* match the one exported. It holds wherever blocks sit side by side, which is
|
||||
* why the quote uses it too and not only the top level.
|
||||
*
|
||||
* A block that renders to nothing is left out rather than contributing a gap.
|
||||
*/
|
||||
function stack(nodes: DocNode[], o: MarkdownOptions, indent = ""): string[] {
|
||||
return nodes.flatMap((n) => {
|
||||
const rendered = block(n, o, indent);
|
||||
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders list items, indenting anything nested inside them. */
|
||||
function list(
|
||||
n: DocNode,
|
||||
@@ -230,19 +256,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
|
||||
}
|
||||
}
|
||||
|
||||
/* A blank line between top-level blocks.
|
||||
*
|
||||
* Without one, Markdown runs adjacent blocks together: a bullet list written
|
||||
* straight after another list becomes more items of the first, and a quote or
|
||||
* a code fence on the line after a list item is read as part of that item.
|
||||
* That is not a quirk of one reader — it is what the format says, so the file
|
||||
* rendered wrongly everywhere, and reading it back in produced a document that
|
||||
* did not match the one exported. */
|
||||
const blocks = (doc.content || []).flatMap((n) => {
|
||||
const rendered = block(n, options);
|
||||
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
|
||||
});
|
||||
lines.push(...blocks);
|
||||
lines.push(...stack(doc.content || [], options));
|
||||
|
||||
if (options.hasDrawing) {
|
||||
lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold.");
|
||||
|
||||
@@ -128,6 +128,11 @@ const STYLES = `
|
||||
li.task .box { font-size: 12pt; line-height: 1; }
|
||||
li.task div p { margin: 0; }
|
||||
blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; }
|
||||
/* A quote may hold headings and lists. Their own spacing is right between
|
||||
them and wrong at the ends, where it pushes the rule beside the quote past
|
||||
the text it belongs to. */
|
||||
blockquote > :first-child { margin-top: 0; }
|
||||
blockquote > :last-child { margin-bottom: 0; }
|
||||
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; }
|
||||
|
||||
@@ -34,6 +34,21 @@ describe("blocks", () => {
|
||||
expect(quote.content![0]!.type).toBe("heading");
|
||||
});
|
||||
|
||||
it("keeps a quote together across the `>` lines that divide it", () => {
|
||||
const quote = first("> #### Kopf\n>\n> - eins\n> - zwei\n>\n> *Alles* nach **Plan**.");
|
||||
expect(quote.type).toBe("blockquote");
|
||||
expect(quote.content!.map((n) => n.type)).toEqual(["heading", "bulletList", "paragraph"]);
|
||||
// A quoted blank line divides; it must not be read as the end of the quote.
|
||||
expect(plain(quote.content![2]!)).toBe("Alles nach Plan.");
|
||||
expect(quote.content![2]!.content![0]!.marks![0]!.type).toBe("italic");
|
||||
});
|
||||
|
||||
it("reads a quote inside a quote", () => {
|
||||
const outer = first("> außen\n>\n> > innen");
|
||||
expect(outer.content!.map((n) => n.type)).toEqual(["paragraph", "blockquote"]);
|
||||
expect(plain(outer.content![1]!)).toBe("innen");
|
||||
});
|
||||
|
||||
it("joins the lines of a paragraph", () => {
|
||||
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
|
||||
});
|
||||
|
||||
@@ -208,7 +208,9 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Icon</div>
|
||||
<IconChooser T={T} value={icon} onPick={setIcon} height={170} />
|
||||
{/* A little taller than it was: the headings between the groups cost a
|
||||
row, and at 170 the first group of marks was the only one in view. */}
|
||||
<IconChooser T={T} value={icon} onPick={setIcon} height={200} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
|
||||
<button
|
||||
|
||||
+248
-77
@@ -1,82 +1,255 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* The marks a note or a chapter can wear, and the grid for choosing one.
|
||||
*
|
||||
* A fixed list rather than the system emoji keyboard. That keyboard is a
|
||||
* different thing on every machine — on Android it is the one for typing, which
|
||||
* means opening it inside a text field the app does not have — and it offers
|
||||
* three thousand pictures for a decision that is "which of these is my invoice
|
||||
* notebook". A few dozen, searchable by what they are for, is the whole job.
|
||||
* A list of one's own rather than the system emoji keyboard. That keyboard is a
|
||||
* different thing on every machine, and it offers three thousand pictures for a
|
||||
* decision that is "which of these is my invoice notebook". Some dozens,
|
||||
* searchable by what they are for, is the whole job — and the search box is a
|
||||
* text field, so the keyboard is still there for whoever wants it.
|
||||
*
|
||||
* The words are what make it usable: nobody hunts a pictogram grid by shape.
|
||||
* They are also why the list is written out rather than generated.
|
||||
*
|
||||
* One component, used by both dialogs that offer a mark. Two grids would drift.
|
||||
* There are two ways a mark gets in. **Adding one to this list is one line** — a
|
||||
* `{ char, words }` in whichever group it belongs to. Nothing else: what is
|
||||
* stored on a note is the character itself, not a number into this list, so the
|
||||
* order may change and entries may come and go without anything already chosen
|
||||
* being disturbed. Give it the words someone would actually search for — what
|
||||
* the thing is *for*, not what it depicts. And **anything else can be typed into
|
||||
* the search box**, which is where the system emoji keyboard lives on a phone;
|
||||
* see `typedMark`.
|
||||
*
|
||||
* The groups exist because the list outgrew a single wall of pictures. They are
|
||||
* headings, not filters: searching looks through all of them at once.
|
||||
*
|
||||
* One component, used by both dialogs that offer a mark. Two grids would drift —
|
||||
* and did: for a while there were two copies of this list, and only one of them
|
||||
* was ever extended.
|
||||
*/
|
||||
export const ICONS = [
|
||||
{ char: "📄", words: "note paper document default" },
|
||||
{ char: "📝", words: "note write draft memo" },
|
||||
{ char: "📌", words: "pin important stick" },
|
||||
{ char: "⭐", words: "star favourite important" },
|
||||
{ char: "❗", words: "important urgent attention" },
|
||||
{ char: "✅", words: "done finished checked task" },
|
||||
{ char: "🕒", words: "later waiting time pending" },
|
||||
{ char: "💡", words: "idea thought concept" },
|
||||
{ char: "🔧", words: "tool fix repair maintenance" },
|
||||
{ char: "🔨", words: "build work project diy" },
|
||||
{ char: "🏠", words: "house home flat property" },
|
||||
{ char: "🔌", words: "power electricity socket energy" },
|
||||
{ char: "💧", words: "water plumbing bill" },
|
||||
{ char: "🔥", words: "heating gas fire boiler" },
|
||||
{ char: "🚗", words: "car vehicle travel garage" },
|
||||
{ char: "🧾", words: "receipt invoice bill accounting" },
|
||||
{ char: "💶", words: "money euro payment bank finance" },
|
||||
{ char: "🏦", words: "bank account finance" },
|
||||
{ char: "📊", words: "chart numbers report statistics" },
|
||||
{ char: "📅", words: "calendar date appointment plan" },
|
||||
{ char: "✈️", words: "travel holiday flight trip" },
|
||||
{ char: "🍳", words: "cooking recipe kitchen food" },
|
||||
{ char: "🛒", words: "shopping list groceries" },
|
||||
{ char: "🎁", words: "gift present birthday" },
|
||||
{ char: "🎵", words: "music song audio" },
|
||||
{ char: "📷", words: "photo picture camera" },
|
||||
{ char: "🎬", words: "film video movie watch" },
|
||||
{ char: "📚", words: "book reading study library" },
|
||||
{ char: "🎓", words: "school study course learning" },
|
||||
{ char: "💼", words: "work job office business" },
|
||||
{ char: "🖥️", words: "computer server it hardware" },
|
||||
{ char: "🌐", words: "web internet network domain" },
|
||||
{ char: "🔒", words: "password secret secure private" },
|
||||
{ char: "🐳", words: "docker container server" },
|
||||
{ char: "🧠", words: "thinking notes knowledge brain" },
|
||||
{ char: "❤️", words: "love personal family" },
|
||||
{ char: "🐾", words: "pet animal dog cat" },
|
||||
{ char: "🌱", words: "garden plant growing nature" },
|
||||
{ char: "⚽", words: "sport football hobby" },
|
||||
{ char: "🏃", words: "running fitness health sport" },
|
||||
{ char: "💊", words: "health medicine doctor" },
|
||||
{ char: "🩺", words: "doctor health medical appointment" },
|
||||
{ char: "📦", words: "package delivery order storage" },
|
||||
{ char: "🗂️", words: "files archive folder sorting" },
|
||||
export const ICON_GROUPS = [
|
||||
{
|
||||
name: "Notes",
|
||||
icons: [
|
||||
{ char: "📄", words: "note paper document default" },
|
||||
{ char: "📝", words: "note write draft memo" },
|
||||
{ char: "✏️", words: "draft writing pencil unfinished" },
|
||||
{ char: "📌", words: "pin important stick" },
|
||||
{ char: "🔖", words: "bookmark marker reference" },
|
||||
{ char: "⭐", words: "star favourite important" },
|
||||
{ char: "❗", words: "important urgent attention" },
|
||||
{ char: "❓", words: "question open unclear" },
|
||||
{ char: "✅", words: "done finished checked task" },
|
||||
{ char: "🕒", words: "later waiting time pending" },
|
||||
{ char: "🚩", words: "flag follow up marked" },
|
||||
{ char: "💡", words: "idea thought concept" },
|
||||
{ char: "🧠", words: "thinking notes knowledge brain" },
|
||||
{ char: "🔍", words: "research looking into search" },
|
||||
{ char: "🗒️", words: "list scratch pad jotting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Home & money",
|
||||
icons: [
|
||||
{ char: "🏠", words: "house home flat property" },
|
||||
{ char: "🔑", words: "keys landlord tenancy access" },
|
||||
{ char: "🔌", words: "power electricity socket energy" },
|
||||
{ char: "💧", words: "water plumbing bill" },
|
||||
{ char: "🔥", words: "heating gas fire boiler" },
|
||||
{ char: "🌡️", words: "heating temperature thermostat climate" },
|
||||
{ char: "🧾", words: "receipt invoice bill accounting" },
|
||||
{ char: "💶", words: "money euro payment bank finance" },
|
||||
{ char: "💳", words: "card payment subscription direct debit" },
|
||||
{ char: "🏦", words: "bank account finance" },
|
||||
{ char: "📊", words: "chart numbers report statistics" },
|
||||
{ char: "📈", words: "growth investment shares budget" },
|
||||
{ char: "🛠️", words: "repair maintenance handyman fixing" },
|
||||
{ char: "🔧", words: "tool fix repair maintenance" },
|
||||
{ char: "🔨", words: "build work project diy" },
|
||||
{ char: "🧹", words: "cleaning chores household" },
|
||||
{ char: "🪑", words: "furniture room interior" },
|
||||
{ char: "📮", words: "post letter mail correspondence" },
|
||||
{ char: "🗑️", words: "waste bin rubbish disposal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Work & study",
|
||||
icons: [
|
||||
{ char: "💼", words: "work job office business" },
|
||||
{ char: "🏢", words: "company employer firm office" },
|
||||
{ char: "📅", words: "calendar date appointment plan" },
|
||||
{ char: "🗓️", words: "schedule planning week year" },
|
||||
{ char: "📚", words: "book reading study library" },
|
||||
{ char: "🎓", words: "school study course learning" },
|
||||
{ char: "👥", words: "meeting people team colleagues" },
|
||||
{ char: "🗣️", words: "talk interview conversation minutes" },
|
||||
{ char: "📞", words: "call phone contact" },
|
||||
{ char: "✉️", words: "email letter message" },
|
||||
{ char: "📎", words: "attachment paperwork documents" },
|
||||
{ char: "🗂️", words: "files archive folder sorting" },
|
||||
{ char: "📦", words: "package delivery order storage" },
|
||||
{ char: "⚖️", words: "legal contract law insurance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Tech",
|
||||
icons: [
|
||||
{ char: "🖥️", words: "computer server it hardware" },
|
||||
{ char: "🌐", words: "web internet network domain" },
|
||||
{ char: "🔒", words: "password secret secure private" },
|
||||
{ char: "🐳", words: "docker container server" },
|
||||
{ char: "⚙️", words: "settings configuration setup" },
|
||||
{ char: "🧩", words: "plugin module integration piece" },
|
||||
{ char: "🗄️", words: "database storage backup archive" },
|
||||
{ char: "📡", words: "network router connection signal" },
|
||||
{ char: "🔋", words: "power battery devices charging" },
|
||||
{ char: "🖨️", words: "printer scanner paperwork device" },
|
||||
{ char: "📱", words: "phone mobile app device" },
|
||||
{ char: "🧪", words: "experiment testing trying out" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Life",
|
||||
icons: [
|
||||
{ char: "✈️", words: "travel holiday flight trip" },
|
||||
{ char: "🧳", words: "packing luggage journey" },
|
||||
{ char: "🏖️", words: "holiday beach break rest" },
|
||||
{ char: "🚗", words: "car vehicle travel garage" },
|
||||
{ char: "🚲", words: "bike cycling commute" },
|
||||
{ char: "🍳", words: "cooking recipe kitchen food" },
|
||||
{ char: "🍽️", words: "restaurant eating out dinner" },
|
||||
{ char: "☕", words: "coffee break cafe morning" },
|
||||
{ char: "🛒", words: "shopping list groceries" },
|
||||
{ char: "🎁", words: "gift present birthday" },
|
||||
{ char: "🎂", words: "birthday anniversary celebration" },
|
||||
{ char: "🎄", words: "christmas holidays festive" },
|
||||
{ char: "🎵", words: "music song audio" },
|
||||
{ char: "📷", words: "photo picture camera" },
|
||||
{ char: "🎬", words: "film video movie watch" },
|
||||
{ char: "🎨", words: "art drawing design creative" },
|
||||
{ char: "🎮", words: "games gaming console hobby" },
|
||||
{ char: "⚽", words: "sport football hobby" },
|
||||
{ char: "🏃", words: "running fitness health sport" },
|
||||
{ char: "🎯", words: "goal aim target plan" },
|
||||
{ char: "🌱", words: "garden plant growing nature" },
|
||||
{ char: "🐾", words: "pet animal dog cat" },
|
||||
{ char: "❤️", words: "love personal family" },
|
||||
{ char: "👶", words: "child children family kids" },
|
||||
{ char: "💊", words: "health medicine doctor" },
|
||||
{ char: "🩺", words: "doctor health medical appointment" },
|
||||
{ char: "🦷", words: "dentist teeth appointment" },
|
||||
{ char: "🧘", words: "wellbeing calm mindfulness routine" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function IconChooser({ T, value, onPick, height = 200 }) {
|
||||
/** Every mark, in one flat list — for searching, and for anything that just
|
||||
wants to know whether a character is one of ours. */
|
||||
export const ICONS = ICON_GROUPS.flatMap((g) => g.icons);
|
||||
|
||||
/**
|
||||
* What the tiles under it are.
|
||||
*
|
||||
* Across the whole grid, and deliberately unlike the tiles: small, faint and
|
||||
* flush left. Shaped like one it would be a button that does nothing when
|
||||
* pressed. `name` is set only on the real groups, so a test can count them.
|
||||
*/
|
||||
function Heading({ T, name, children }) {
|
||||
return (
|
||||
<div
|
||||
{...(name ? { "data-icon-group": name } : {})}
|
||||
style={{
|
||||
gridColumn: "1 / -1",
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
textTransform: "uppercase",
|
||||
color: T.muted,
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group heading matches too: "tech" should find the tech ones. */
|
||||
const matches = (icon, group, q) =>
|
||||
icon.words.includes(q) || icon.char === q || group.toLowerCase().includes(q);
|
||||
|
||||
/**
|
||||
* What was typed, when it is a picture rather than a word.
|
||||
*
|
||||
* The list above is deliberately short — but the search box is a text field, and
|
||||
* on a phone a text field is exactly where the system emoji keyboard lives. So a
|
||||
* mark nobody thought of can still be used: type or paste it, and it is offered
|
||||
* as a tile. That is the second way marks get in, and it needs no release.
|
||||
*
|
||||
* Eight code points, the same limit the server keeps, so one glyph with its
|
||||
* modifiers fits and a pasted sentence does not. Letters, digits and spaces mean
|
||||
* a word was typed and a search is meant, not a mark.
|
||||
*/
|
||||
export function typedMark(query) {
|
||||
const raw = [...query.trim()].slice(0, 8).join("");
|
||||
if (!raw || /[\p{L}\p{N}\s]/u.test(raw)) return null;
|
||||
if (!/\p{Extended_Pictographic}/u.test(raw)) return null;
|
||||
return ICONS.some((i) => i.char === raw) ? null : { char: raw, words: "typed" };
|
||||
}
|
||||
|
||||
export function IconChooser({ T, value, onPick, height = 200, autoFocus = false, onEscape = null }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ICONS;
|
||||
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
|
||||
if (!q) return ICON_GROUPS;
|
||||
return ICON_GROUPS.map((g) => ({ ...g, icons: g.icons.filter((i) => matches(i, g.name, q)) })).filter(
|
||||
(g) => g.icons.length > 0
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
/* Marks that are not in the list: what was just typed, and what this row is
|
||||
already wearing if it was typed some other day. Without the second one, a
|
||||
mark chosen that way was invisible when the dialog was opened again and
|
||||
looked as though it had been lost. */
|
||||
const loose = useMemo(() => {
|
||||
const own = value && !ICONS.some((i) => i.char === value) ? { char: value, words: "chosen" } : null;
|
||||
const typed = typedMark(query);
|
||||
return [own, typed && typed.char !== own?.char ? typed : null].filter(Boolean);
|
||||
}, [query, value]);
|
||||
|
||||
const tile = (icon) => (
|
||||
<button
|
||||
key={icon.char}
|
||||
type="button"
|
||||
data-icon-option={icon.char}
|
||||
title={icon.words.split(" ")[0]}
|
||||
onClick={() => onPick(icon.char)}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
|
||||
background: value === icon.char ? `${T.accent}22` : "transparent",
|
||||
}}
|
||||
>
|
||||
{icon.char}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Escape" && onEscape?.()}
|
||||
aria-label="Search icons"
|
||||
placeholder="Search — invoice, house, travel…"
|
||||
placeholder="Search — invoice, house, travel… or type an emoji"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "9px 12px",
|
||||
@@ -100,7 +273,11 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
|
||||
}}
|
||||
>
|
||||
{/* "None" belongs in the grid rather than off in a corner: taking a mark
|
||||
off is the same kind of choice as putting one on. */}
|
||||
off is the same kind of choice as putting one on. It stays put while
|
||||
searching — it is not one of the pictures being searched through —
|
||||
and it carries a heading like every other row, or it stands alone in
|
||||
a line of empty space and reads as a gap. */}
|
||||
<Heading T={T}>None</Heading>
|
||||
<button
|
||||
type="button"
|
||||
data-icon-option="none"
|
||||
@@ -120,27 +297,21 @@ export function IconChooser({ T, value, onPick, height = 200 }) {
|
||||
>
|
||||
—
|
||||
</button>
|
||||
{shown.map((icon) => (
|
||||
<button
|
||||
key={icon.char}
|
||||
type="button"
|
||||
data-icon-option={icon.char}
|
||||
title={icon.words.split(" ")[0]}
|
||||
onClick={() => onPick(icon.char)}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
border: value === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
|
||||
background: value === icon.char ? `${T.accent}22` : "transparent",
|
||||
}}
|
||||
>
|
||||
{icon.char}
|
||||
</button>
|
||||
{loose.length > 0 && (
|
||||
<>
|
||||
<Heading T={T}>Typed</Heading>
|
||||
{loose.map(tile)}
|
||||
</>
|
||||
)}
|
||||
{shown.map((group) => (
|
||||
<Fragment key={group.name}>
|
||||
<Heading T={T} name={group.name}>
|
||||
{group.name}
|
||||
</Heading>
|
||||
{group.icons.map(tile)}
|
||||
</Fragment>
|
||||
))}
|
||||
{shown.length === 0 && (
|
||||
{shown.length === 0 && loose.length === 0 && (
|
||||
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
|
||||
Nothing by that name.
|
||||
</div>
|
||||
|
||||
+11
-139
@@ -1,75 +1,22 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CHAPTER_COLORS } from "./constants.jsx";
|
||||
import { IconChooser } from "./IconChooser.jsx";
|
||||
import { useBackdropClose } from "./backdrop";
|
||||
|
||||
/**
|
||||
* Choosing the mark a note or a chapter wears beside its name.
|
||||
* Choosing the mark a note wears beside its name, and the colour that goes with
|
||||
* it.
|
||||
*
|
||||
* A fixed list rather than the system emoji keyboard. That keyboard is a
|
||||
* different thing on every machine — on Android it is the one for typing, which
|
||||
* means opening it inside a text field the app does not have — and it offers
|
||||
* three thousand pictures for a decision that is "which of these is my invoice
|
||||
* notebook". A few dozen, searchable by what they are for, is the whole job.
|
||||
*
|
||||
* The words are what makes it usable: nobody hunts a pictogram grid by shape.
|
||||
* They are also the reason the list is written out rather than generated.
|
||||
*/
|
||||
const ICONS = [
|
||||
{ char: "📄", words: "note paper document default" },
|
||||
{ char: "📝", words: "note write draft memo" },
|
||||
{ char: "📌", words: "pin important stick" },
|
||||
{ char: "⭐", words: "star favourite important" },
|
||||
{ char: "❗", words: "important urgent attention" },
|
||||
{ char: "✅", words: "done finished checked task" },
|
||||
{ char: "🕒", words: "later waiting time pending" },
|
||||
{ char: "💡", words: "idea thought concept" },
|
||||
{ char: "🔧", words: "tool fix repair maintenance" },
|
||||
{ char: "🔨", words: "build work project diy" },
|
||||
{ char: "🏠", words: "house home flat property" },
|
||||
{ char: "🔌", words: "power electricity socket energy" },
|
||||
{ char: "💧", words: "water plumbing bill" },
|
||||
{ char: "🔥", words: "heating gas fire boiler" },
|
||||
{ char: "🚗", words: "car vehicle travel garage" },
|
||||
{ char: "🧾", words: "receipt invoice bill accounting" },
|
||||
{ char: "💶", words: "money euro payment bank finance" },
|
||||
{ char: "🏦", words: "bank account finance" },
|
||||
{ char: "📊", words: "chart numbers report statistics" },
|
||||
{ char: "📅", words: "calendar date appointment plan" },
|
||||
{ char: "✈️", words: "travel holiday flight trip" },
|
||||
{ char: "🍳", words: "cooking recipe kitchen food" },
|
||||
{ char: "🛒", words: "shopping list groceries" },
|
||||
{ char: "🎁", words: "gift present birthday" },
|
||||
{ char: "🎵", words: "music song audio" },
|
||||
{ char: "📷", words: "photo picture camera" },
|
||||
{ char: "🎬", words: "film video movie watch" },
|
||||
{ char: "📚", words: "book reading study library" },
|
||||
{ char: "🎓", words: "school study course learning" },
|
||||
{ char: "💼", words: "work job office business" },
|
||||
{ char: "🖥️", words: "computer server it hardware" },
|
||||
{ char: "🌐", words: "web internet network domain" },
|
||||
{ char: "🔒", words: "password secret secure private" },
|
||||
{ char: "🐳", words: "docker container server" },
|
||||
{ char: "🧠", words: "thinking notes knowledge brain" },
|
||||
{ char: "❤️", words: "love personal family" },
|
||||
{ char: "🐾", words: "pet animal dog cat" },
|
||||
{ char: "🌱", words: "garden plant growing nature" },
|
||||
{ char: "⚽", words: "sport football hobby" },
|
||||
{ char: "🏃", words: "running fitness health sport" },
|
||||
{ char: "💊", words: "health medicine doctor" },
|
||||
{ char: "🩺", words: "doctor health medical appointment" },
|
||||
{ char: "📦", words: "package delivery order storage" },
|
||||
{ char: "🗂️", words: "files archive folder sorting" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The picker.
|
||||
* The grid itself is `IconChooser` — the same one the chapter dialog uses. It
|
||||
* had been copied here, and the copy fell behind: marks added to one list did
|
||||
* not exist in the other, and which of the two you got depended on which dialog
|
||||
* you happened to open.
|
||||
*
|
||||
* `value` and `colour` are what the row wears now; "None" is always offered,
|
||||
* because a mark that cannot be taken off again is a trap.
|
||||
*/
|
||||
export function IconPicker({ title, value = null, colour = null, onSave, onClose, dark }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [picked, setPicked] = useState(value);
|
||||
const [tint, setTint] = useState(colour);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
@@ -86,12 +33,6 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
|
||||
[dark]
|
||||
);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ICONS;
|
||||
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
|
||||
}, [query]);
|
||||
|
||||
const save = (char, colourValue) => {
|
||||
onSave(char, colourValue);
|
||||
onClose();
|
||||
@@ -125,62 +66,7 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
|
||||
>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
|
||||
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
||||
aria-label="Search icons"
|
||||
placeholder="Search — invoice, house, travel…"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "9px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.field,
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
|
||||
gap: 6,
|
||||
marginTop: 14,
|
||||
maxHeight: 240,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{shown.map((icon) => (
|
||||
<button
|
||||
key={icon.char}
|
||||
type="button"
|
||||
data-icon-option={icon.char}
|
||||
title={icon.words.split(" ")[0]}
|
||||
onClick={() => setPicked(icon.char)}
|
||||
style={{
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
fontSize: 21,
|
||||
lineHeight: 1,
|
||||
border: picked === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
|
||||
background: picked === icon.char ? `${T.accent}22` : "transparent",
|
||||
}}
|
||||
>
|
||||
{icon.char}
|
||||
</button>
|
||||
))}
|
||||
{shown.length === 0 && (
|
||||
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
|
||||
Nothing by that name.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IconChooser T={T} value={picked} onPick={setPicked} height={240} autoFocus onEscape={onClose} />
|
||||
|
||||
{/* The tint is for the marks that are drawn from the app's own set — a
|
||||
coloured emoji ignores it, which is why it is offered quietly. */}
|
||||
@@ -218,24 +104,10 @@ export function IconPicker({ title, value = null, colour = null, onSave, onClose
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No "No icon" button down here: the grid's own "—" says it, and two
|
||||
ways to do one thing differ in what else they do — that one saved and
|
||||
closed in a single press while every other choice waited for Save. */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
|
||||
<button
|
||||
type="button"
|
||||
data-icon-clear
|
||||
onClick={() => save(null, null)}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.muted,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
marginRight: "auto",
|
||||
}}
|
||||
>
|
||||
No icon
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* The list of marks is written out by hand, and adding one is meant to be a
|
||||
* single line. These are the two mistakes that line can make. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ICONS, ICON_GROUPS, typedMark } from "./IconChooser.jsx";
|
||||
|
||||
describe("the marks a note can wear", () => {
|
||||
it("holds each character once", () => {
|
||||
// A repeat is invisible in the grid and breaks the row it sits in: the
|
||||
// character is the key, and it is also what a saved note stores.
|
||||
const seen = ICONS.map((i) => i.char);
|
||||
expect(seen.length).toBe(new Set(seen).size);
|
||||
});
|
||||
|
||||
it("gives every mark words to be found by", () => {
|
||||
// Nobody hunts a pictogram grid by shape, so a mark with no words is a mark
|
||||
// nobody will ever find.
|
||||
const wordless = ICONS.filter((i) => i.words.trim().split(/\s+/).length < 2);
|
||||
expect(wordless.map((i) => i.char)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every mark in a group", () => {
|
||||
expect(ICON_GROUPS.every((g) => g.name && g.icons.length > 0)).toBe(true);
|
||||
expect(ICONS.length).toBe(ICON_GROUPS.reduce((n, g) => n + g.icons.length, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a mark typed into the search box", () => {
|
||||
it("offers a picture that is not in the list", () => {
|
||||
expect(typedMark("🦖")?.char).toBe("🦖");
|
||||
expect(typedMark(" 🛰️ ")?.char).toBe("🛰️");
|
||||
});
|
||||
|
||||
it("offers nothing for a word, since that is a search", () => {
|
||||
expect(typedMark("house")).toBe(null);
|
||||
expect(typedMark("")).toBe(null);
|
||||
expect(typedMark("2")).toBe(null);
|
||||
// Punctuation is not a picture either, whatever it is made of.
|
||||
expect(typedMark("!?")).toBe(null);
|
||||
});
|
||||
|
||||
it("offers nothing for a mark the list already has", () => {
|
||||
expect(typedMark("🏠")).toBe(null);
|
||||
});
|
||||
|
||||
it("takes one glyph, not a pasted sentence", () => {
|
||||
// The server keeps eight code points; anything longer is cut to the same.
|
||||
expect([...(typedMark("🦖🦕🐊🐢🦎🐍🦂🕷️🦀")?.char ?? "")].length).toBe(8);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user