makersclaw 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +5 -5
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/templates/next/files/README.md +77 -19
- package/templates/next/files/app/auto-refresh.jsx +39 -74
- package/templates/next/files/app/frame.js +197 -6
- package/templates/next/files/check/run.mjs +96 -11
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +52 -17
- package/templates/next/template.json +2 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useId } from "react";
|
|
4
4
|
import { cx } from "../../ui";
|
|
5
|
-
import { defaultFormat, idFrom, num, tone, useReveal, useWidth } from "./core";
|
|
5
|
+
import { defaultFormat, idFrom, num, runs, tone, useReveal, useWidth } from "./core";
|
|
6
6
|
|
|
7
7
|
// Sparkline — a trend with no axes, the size of a line of text: beside a
|
|
8
8
|
// KPI, in a table cell. Its scale is its own low to high (not zero), so
|
|
@@ -26,14 +26,20 @@ export function Sparkline({
|
|
|
26
26
|
const [boxRef, width] = useWidth();
|
|
27
27
|
const shown = useReveal(width > 0);
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Every entry keeps its place along the width; a missing one is a gap —
|
|
30
|
+
// the line breaks there — never a zero. The scale is the values there are.
|
|
31
|
+
const raw = (Array.isArray(data) ? data : []).map((d) => num(typeof d === "object" && d !== null ? d[value] : d));
|
|
32
|
+
const vals = raw.filter((v) => v != null);
|
|
30
33
|
const n = vals.length;
|
|
31
34
|
const lo = n ? Math.min(...vals) : 0;
|
|
32
35
|
const hi = n ? Math.max(...vals) : 0;
|
|
33
36
|
const pad = 3;
|
|
34
|
-
const px = (i) => pad + (
|
|
37
|
+
const px = (i) => pad + (raw.length === 1 ? (width - pad * 2) / 2 : (i * (width - pad * 2)) / (raw.length - 1));
|
|
35
38
|
const py = (v) => pad + (hi === lo ? (height - pad * 2) / 2 : (1 - (v - lo) / (hi - lo)) * (height - pad * 2));
|
|
36
|
-
const
|
|
39
|
+
const segs = runs(raw.map((v, i) => (v == null ? null : [px(i), py(v)])));
|
|
40
|
+
const path = (seg) => seg.map(([x, y], i) => `${i ? "L" : "M"}${f(x)},${f(y)}`).join("");
|
|
41
|
+
const lastSeg = segs[segs.length - 1];
|
|
42
|
+
const last = lastSeg?.[lastSeg.length - 1];
|
|
37
43
|
const words = n
|
|
38
44
|
? `${label}: from ${format(vals[0])} to ${format(vals[n - 1])}, low ${format(lo)}, high ${format(hi)}.`
|
|
39
45
|
: `${label}: no data yet.`;
|
|
@@ -68,16 +74,28 @@ export function Sparkline({
|
|
|
68
74
|
</mask>
|
|
69
75
|
</defs>
|
|
70
76
|
<g clipPath={`url(#${uid}-clip)`}>
|
|
71
|
-
{area
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
{area
|
|
78
|
+
? segs.map((seg, k) =>
|
|
79
|
+
seg.length > 1 ? (
|
|
80
|
+
<path
|
|
81
|
+
key={k}
|
|
82
|
+
d={`${path(seg)}L${f(seg[seg.length - 1][0])},${height}L${f(seg[0][0])},${height}Z`}
|
|
83
|
+
className={tone(t, "fill")}
|
|
84
|
+
fillOpacity={0.3}
|
|
85
|
+
mask={`url(#${uid}-fade)`}
|
|
86
|
+
/>
|
|
87
|
+
) : null,
|
|
88
|
+
)
|
|
89
|
+
: null}
|
|
90
|
+
{segs.map((seg, k) =>
|
|
91
|
+
seg.length > 1 ? (
|
|
92
|
+
<path key={k} d={path(seg)} fill="none" className={tone(t, "stroke")} strokeWidth={1.75} strokeLinejoin="round" strokeLinecap="round" />
|
|
93
|
+
) : dot && seg === lastSeg ? null : (
|
|
94
|
+
// A value alone between two gaps: a dot the line's width.
|
|
95
|
+
<circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={1.75} className={tone(t, "fill")} />
|
|
96
|
+
),
|
|
97
|
+
)}
|
|
98
|
+
{dot ? <circle cx={last[0]} cy={last[1]} r={2.5} className={tone(t, "fill")} /> : null}
|
|
81
99
|
</g>
|
|
82
100
|
</>
|
|
83
101
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
-
import { Avatar, Button, Collapsible, IconButton, Icon, Menu, Spinner, Textarea, Toggle, Tooltip, cx } from "../../ui";
|
|
4
|
+
import { Avatar, Button, Collapsible, IconButton, Icon, Menu, Spinner, Textarea, Toggle, Tooltip, cx, toLocalDate } from "../../ui";
|
|
5
5
|
import { Attachment } from "./attachment";
|
|
6
6
|
import { ChatText } from "./text";
|
|
7
7
|
|
|
@@ -25,10 +25,10 @@ export function textOf(message) {
|
|
|
25
25
|
.join("");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A Date, an ISO time or a day alone ("2026-09-28": that day's local
|
|
29
|
+
* midnight, never UTC's) → a Date; null when it is none. */
|
|
28
30
|
export function toDate(value) {
|
|
29
|
-
|
|
30
|
-
const d = value instanceof Date ? value : new Date(value);
|
|
31
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
31
|
+
return toLocalDate(value);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function formatTime(date) {
|
|
@@ -30,7 +30,7 @@ A comment in `comments`:
|
|
|
30
30
|
id: "c-81",
|
|
31
31
|
author: { id: "u-lena", name: "Lena Fischer", src: "https://…/lena.jpg" }, // or just "Lena Fischer"
|
|
32
32
|
body: [ /* the doc onPost gave you */ ], // or a plain string
|
|
33
|
-
createdAt: "2026-09-23T09:12:00Z", // ISO string, Date or
|
|
33
|
+
createdAt: "2026-09-23T09:12:00Z", // ISO string, Date, ms, or a day ("2026-09-23": that day, never UTC's)
|
|
34
34
|
edited: false, // true adds "(edited)"
|
|
35
35
|
}
|
|
36
36
|
```
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// tick (not a timer per comment) keeps every label current. The full date
|
|
9
9
|
// is always in the title.
|
|
10
10
|
import { useEffect, useState } from "react";
|
|
11
|
-
import { cx } from "../../ui";
|
|
11
|
+
import { cx, toLocalDate } from "../../ui";
|
|
12
12
|
|
|
13
13
|
const listeners = new Set();
|
|
14
14
|
let timer = null;
|
|
@@ -34,11 +34,9 @@ export function useNow() {
|
|
|
34
34
|
return now;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
41
|
-
};
|
|
37
|
+
/** A Date, an ISO time or a day alone ("2026-09-28", a `date` column: that
|
|
38
|
+
* day's local midnight, never UTC's) → a Date; null when it is none. */
|
|
39
|
+
export const toDate = (v) => toLocalDate(v);
|
|
42
40
|
|
|
43
41
|
const fmt = (opts) => new Intl.DateTimeFormat(undefined, opts);
|
|
44
42
|
const TIME = { hour: "numeric", minute: "2-digit" };
|
|
@@ -70,6 +70,10 @@ one can carry its own (report-table's `kind`).
|
|
|
70
70
|
- `label` — what the table is called to a screen reader, default `"Table"`.
|
|
71
71
|
- `noun` — what a row is ("invoice"): used in "1–10 of 60 invoices", the
|
|
72
72
|
facet counts and the announcements. Default `"row"`.
|
|
73
|
+
- `nounPlural` — the plural, when the regular ending is wrong for it:
|
|
74
|
+
`noun="person" nounPlural="people"`. Default: `noun` with the regular
|
|
75
|
+
English ending — "invoice" → "invoices", "company" → "companies",
|
|
76
|
+
"address" → "addresses", "batch" → "batches", "analysis" → "analyses".
|
|
73
77
|
|
|
74
78
|
Search
|
|
75
79
|
|
|
@@ -101,7 +101,19 @@ function humanize(key) {
|
|
|
101
101
|
return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
/** A noun's plural by the regular English endings: "invoice" → "invoices",
|
|
105
|
+
* "company" → "companies", "address" → "addresses", "batch" → "batches",
|
|
106
|
+
* "analysis" → "analyses". Anything else is the table's `nounPlural`
|
|
107
|
+
* ("person" → "people"). Until 0.5.1 it only added "s": "11 companys". */
|
|
108
|
+
export function pluralOf(noun) {
|
|
109
|
+
const s = String(noun ?? "");
|
|
110
|
+
if (/[^aeiou]y$/.test(s)) return `${s.slice(0, -1)}ies`;
|
|
111
|
+
if (/sis$/.test(s)) return `${s.slice(0, -2)}es`;
|
|
112
|
+
if (/(?:s|x|z|ch|sh)$/.test(s)) return `${s}es`;
|
|
113
|
+
return `${s}s`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function plural(n, one, many = pluralOf(one)) {
|
|
105
117
|
return `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -186,12 +198,14 @@ export function DataTable({
|
|
|
186
198
|
emptyAction,
|
|
187
199
|
emptyIcon = "inbox",
|
|
188
200
|
noun = "row",
|
|
201
|
+
nounPlural,
|
|
189
202
|
mobile = "scroll",
|
|
190
203
|
cardTitle,
|
|
191
204
|
variant = "outline",
|
|
192
205
|
className,
|
|
193
206
|
}) {
|
|
194
207
|
const uid = useId().replace(/[^\w-]/g, "");
|
|
208
|
+
const nouns = nounPlural || pluralOf(noun);
|
|
195
209
|
const all = rows ?? NONE;
|
|
196
210
|
const cols = useMemo(() => (columns ?? []).map((c) => ({ ...c, key: String(c.key), header: c.header ?? humanize(c.key) })), [columns]);
|
|
197
211
|
const facetList = useMemo(
|
|
@@ -261,8 +275,8 @@ export function DataTable({
|
|
|
261
275
|
}, [current, pageCount, loading]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
262
276
|
|
|
263
277
|
useEffect(() => {
|
|
264
|
-
if (!loading && narrowed) setAnnounce(total === 0 ? "No matches" : `${plural(total, noun)} match`);
|
|
265
|
-
}, [total, narrowed, loading, noun]);
|
|
278
|
+
if (!loading && narrowed) setAnnounce(total === 0 ? "No matches" : `${plural(total, noun, nouns)} match`);
|
|
279
|
+
}, [total, narrowed, loading, noun, nouns]);
|
|
266
280
|
|
|
267
281
|
// ---- changes that move back to page 1 ---------------------------------
|
|
268
282
|
|
|
@@ -358,7 +372,7 @@ export function DataTable({
|
|
|
358
372
|
return;
|
|
359
373
|
}
|
|
360
374
|
if (action.keepSelection !== true) setPicked([]);
|
|
361
|
-
setAnnounce(`${action.label}: done for ${plural(real.length, noun)}`);
|
|
375
|
+
setAnnounce(`${action.label}: done for ${plural(real.length, noun, nouns)}`);
|
|
362
376
|
}
|
|
363
377
|
|
|
364
378
|
// ---- drawing -----------------------------------------------------------
|
|
@@ -588,7 +602,7 @@ export function DataTable({
|
|
|
588
602
|
<Empty
|
|
589
603
|
icon="search"
|
|
590
604
|
title="No matches"
|
|
591
|
-
body={narrowed ? "Nothing matches the search and filters." : `No ${
|
|
605
|
+
body={narrowed ? "Nothing matches the search and filters." : `No ${nouns} on this page.`}
|
|
592
606
|
action={
|
|
593
607
|
narrowed ? (
|
|
594
608
|
<Button size="sm" icon="x" onClick={clearFilters}>
|
|
@@ -1137,7 +1151,7 @@ export function DataTable({
|
|
|
1137
1151
|
{paginate && !body && (total > 0 || loading) ? (
|
|
1138
1152
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
1139
1153
|
<p data-nums className="w-full text-sm text-muted-foreground sm:w-auto" id={`${uid}-range`}>
|
|
1140
|
-
{loading ? "Loading…" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun)}`}
|
|
1154
|
+
{loading ? "Loading…" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun, nouns)}`}
|
|
1141
1155
|
</p>
|
|
1142
1156
|
<Pagination
|
|
1143
1157
|
page={pageNow}
|
|
@@ -68,8 +68,10 @@ export function FlowEdge({ id, source, target, selected, data, label }) {
|
|
|
68
68
|
const g = connectorGeometry(p1, sides.source, p2, sides.target, data?.line ?? "curved");
|
|
69
69
|
const arrow = data?.arrow ?? "end";
|
|
70
70
|
const editing = !readOnly && editingEdge === id;
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// `\_\_` and String.raw: an underscore in [...] is a space to Tailwind
|
|
72
|
+
// (theme.js says why).
|
|
73
|
+
const ink = selected ? "stroke-primary" : String.raw`stroke-muted-foreground [.react-flow\_\_edge:hover_&]:stroke-foreground`;
|
|
74
|
+
const fill = selected ? "fill-primary" : String.raw`fill-muted-foreground [.react-flow\_\_edge:hover_&]:fill-foreground`;
|
|
73
75
|
return (
|
|
74
76
|
<>
|
|
75
77
|
<path d={g.d} fill="none" stroke="transparent" strokeWidth={18} className="react-flow__edge-interaction" />
|
|
@@ -10,71 +10,82 @@
|
|
|
10
10
|
// one thing that outranks it. Our own nodes and connectors draw with
|
|
11
11
|
// token classes directly (nodes.jsx, edges.jsx), so the library's node and
|
|
12
12
|
// edge colours are never seen.
|
|
13
|
+
//
|
|
14
|
+
// React Flow's classes are BEM, `react-flow__node`, and Tailwind reads an
|
|
15
|
+
// underscore inside [...] as a space: written plainly, every rule below
|
|
16
|
+
// compiled to `.react-flow node`, which matches nothing, and none of this
|
|
17
|
+
// theme ever reached the page — the attribution was the library's grey box
|
|
18
|
+
// with its grey text, unreadable on a dark workspace (makersclaw-store
|
|
19
|
+
// BLOCKS.md, mind-maps 0.1.0). `\_` keeps an underscore, and String.raw
|
|
20
|
+
// keeps the backslash in the class name, where the rule Tailwind builds
|
|
21
|
+
// from this file's text expects it. Every React Flow class below is
|
|
22
|
+
// written rf`…`; test/cli.test.mjs refuses a plain one in any block.
|
|
23
|
+
const rf = String.raw;
|
|
13
24
|
export const THEME = [
|
|
14
25
|
// the canvas: the page's background with dots in the muted ink
|
|
15
26
|
"[--xy-background-color:transparent]!",
|
|
16
27
|
"[--xy-background-pattern-color:var(--color-muted-foreground)]!",
|
|
17
|
-
|
|
28
|
+
rf`[&_.react-flow\_\_background]:opacity-35!`,
|
|
18
29
|
// the marquee, and the box around a multi-selection
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
rf`[&_.react-flow\_\_selection]:bg-primary/8!`,
|
|
31
|
+
rf`[&_.react-flow\_\_selection]:border!`,
|
|
32
|
+
rf`[&_.react-flow\_\_selection]:border-solid!`,
|
|
33
|
+
rf`[&_.react-flow\_\_selection]:border-primary/70!`,
|
|
34
|
+
rf`[&_.react-flow\_\_selection]:rounded-sm!`,
|
|
35
|
+
rf`[&_.react-flow\_\_nodesselection-rect]:bg-transparent!`,
|
|
36
|
+
rf`[&_.react-flow\_\_nodesselection-rect]:border!`,
|
|
37
|
+
rf`[&_.react-flow\_\_nodesselection-rect]:border-dashed!`,
|
|
38
|
+
rf`[&_.react-flow\_\_nodesselection-rect]:border-primary/60!`,
|
|
39
|
+
rf`[&_.react-flow\_\_nodesselection-rect]:rounded-sm!`,
|
|
29
40
|
// the line drawn while a connector is being dragged out
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
rf`[&_.react-flow\_\_connection-path]:stroke-primary!`,
|
|
42
|
+
rf`[&_.react-flow\_\_connection-path]:[stroke-width:2]!`,
|
|
32
43
|
// cursors that match the tool (flow-canvas.jsx sets data-tool on the root)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
rf`[&_.react-flow\_\_pane.selection]:cursor-default!`,
|
|
45
|
+
rf`[&_.react-flow\_\_node.draggable]:cursor-default!`,
|
|
46
|
+
rf`[&_.react-flow\_\_node.draggable.dragging]:cursor-grabbing!`,
|
|
47
|
+
rf`data-[tool=hand]:[&_.react-flow\_\_pane]:cursor-grab!`,
|
|
48
|
+
rf`data-[tool=hand]:[&_.react-flow\_\_pane.dragging]:cursor-grabbing!`,
|
|
49
|
+
rf`data-[tool=hand]:[&_.react-flow\_\_node]:cursor-grab!`,
|
|
50
|
+
rf`data-[tool=place]:[&_.react-flow\_\_pane]:cursor-crosshair!`,
|
|
51
|
+
rf`data-[tool=text]:[&_.react-flow\_\_pane]:cursor-text!`,
|
|
52
|
+
rf`data-[tool=connector]:[&_.react-flow\_\_node]:cursor-crosshair!`,
|
|
42
53
|
// keyboard focus on a card: a visible ring (the library turns it off)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
rf`[&_.react-flow\_\_node:focus-visible]:outline-2!`,
|
|
55
|
+
rf`[&_.react-flow\_\_node:focus-visible]:outline-offset-4!`,
|
|
56
|
+
rf`[&_.react-flow\_\_node:focus-visible]:outline-ring!`,
|
|
57
|
+
rf`[&_.react-flow\_\_node:focus-visible]:[outline-style:solid]!`,
|
|
47
58
|
// frames: only their title and edge take the pointer, so a double-click
|
|
48
59
|
// or a marquee inside one lands on the canvas (nodes.jsx)
|
|
49
|
-
|
|
60
|
+
rf`[&_.react-flow\_\_node-frame]:pointer-events-none!`,
|
|
50
61
|
// the resize handles on a selected card
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
rf`[&_.react-flow\_\_resize-control.handle]:size-2.5!`,
|
|
63
|
+
rf`[&_.react-flow\_\_resize-control.handle]:rounded-xs!`,
|
|
64
|
+
rf`[&_.react-flow\_\_resize-control.handle]:border-primary!`,
|
|
65
|
+
rf`[&_.react-flow\_\_resize-control.handle]:bg-background!`,
|
|
66
|
+
rf`[&_.react-flow\_\_resize-control.handle]:pointer-events-auto!`,
|
|
67
|
+
rf`[&_.react-flow\_\_resize-control.line]:border-primary/0!`,
|
|
68
|
+
rf`[&_.react-flow\_\_resize-control.line]:pointer-events-auto!`,
|
|
69
|
+
rf`[&_.react-flow\_\_resize-control.line.left]:w-2!`,
|
|
70
|
+
rf`[&_.react-flow\_\_resize-control.line.right]:w-2!`,
|
|
71
|
+
rf`[&_.react-flow\_\_resize-control.line.top]:h-2!`,
|
|
72
|
+
rf`[&_.react-flow\_\_resize-control.line.bottom]:h-2!`,
|
|
62
73
|
// the minimap: a card with the board in muted ink
|
|
63
74
|
"[--xy-minimap-background-color:var(--color-card)]!",
|
|
64
75
|
"[--xy-minimap-node-background-color:var(--color-muted-foreground)]!",
|
|
65
76
|
"[--xy-minimap-mask-background-color:var(--color-muted)]!",
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
rf`[&_.react-flow\_\_minimap]:rounded-lg!`,
|
|
78
|
+
rf`[&_.react-flow\_\_minimap]:border!`,
|
|
79
|
+
rf`[&_.react-flow\_\_minimap]:border-border!`,
|
|
80
|
+
rf`[&_.react-flow\_\_minimap]:shadow-md!`,
|
|
81
|
+
rf`[&_.react-flow\_\_minimap]:overflow-hidden!`,
|
|
82
|
+
rf`[&_.react-flow\_\_minimap-node]:opacity-50!`,
|
|
83
|
+
rf`[&_.react-flow\_\_minimap-mask]:opacity-70!`,
|
|
73
84
|
// React Flow's credit: kept (they ask for it), in the muted ink
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
rf`[&_.react-flow\_\_attribution]:bg-transparent!`,
|
|
86
|
+
rf`[&_.react-flow\_\_attribution_a]:text-muted-foreground!`,
|
|
76
87
|
// panels: ours sit in the corners with our own spacing
|
|
77
|
-
|
|
88
|
+
rf`[&_.react-flow\_\_panel]:m-3!`,
|
|
78
89
|
].join(" ");
|
|
79
90
|
|
|
80
91
|
// Colour TOKENS → classes, written out whole so Tailwind generates them. A
|
|
@@ -14,6 +14,11 @@ reader, and a quick way to jump to a topic.
|
|
|
14
14
|
It is built on `flow-canvas` (which draws it, pans and zooms): this block
|
|
15
15
|
owns the tree, the layout and the keys.
|
|
16
16
|
|
|
17
|
+
A map opens fitted to its view, but never smaller than its words can be
|
|
18
|
+
read: 79%, where its smallest words (14px) draw at the kit's smallest
|
|
19
|
+
text size (11px). A map too big for that — most maps on a phone — opens
|
|
20
|
+
at 79% centred on its centre topic; ⇧1 still fits the whole of it.
|
|
21
|
+
|
|
17
22
|
```jsx
|
|
18
23
|
import { MindMap } from "../components/blocks/mind-map"; // a Makersclaw app ("./blocks/mind-map" from components/)
|
|
19
24
|
import { MindMap } from "@/components/makersclaw/blocks/mind-map"; // a shadcn project
|
|
@@ -64,7 +69,9 @@ string, or nothing (a fresh map). The component runs it on `value`.
|
|
|
64
69
|
|
|
65
70
|
- `value` — the map, controlled. A new `value` that is not what the map
|
|
66
71
|
last sent through `onChange` replaces it (loading another record); that
|
|
67
|
-
replacement can be undone
|
|
72
|
+
replacement can be undone, and when it is another map (a new centre, or
|
|
73
|
+
other main branches) the view fits it, as it fits a first one. Pass
|
|
74
|
+
back exactly what `onChange` gave you.
|
|
68
75
|
- `defaultValue` — the starting map, uncontrolled. With neither, a centre
|
|
69
76
|
topic called "Central topic" and the first-run card.
|
|
70
77
|
- `onChange(map)` — the whole tree after a change settles (debounced by
|
|
@@ -114,7 +121,7 @@ With a topic selected (click one, or press an arrow):
|
|
|
114
121
|
| ⌘Z / ⇧⌘Z | undo / redo |
|
|
115
122
|
| Home | the centre |
|
|
116
123
|
| Esc | clear the selection |
|
|
117
|
-
| ⇧1 | fit the map to the screen |
|
|
124
|
+
| ⇧1 | fit the whole map to the screen, however small |
|
|
118
125
|
| ? | the shortcuts sheet |
|
|
119
126
|
|
|
120
127
|
While typing a topic: **Enter** finishes it and starts the next one (on
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
mindMapToText,
|
|
34
34
|
moveTopic,
|
|
35
35
|
normalizeMap,
|
|
36
|
+
openingView,
|
|
36
37
|
outdentTopic,
|
|
37
38
|
outlineToTopics,
|
|
38
39
|
parentOf,
|
|
@@ -49,14 +50,20 @@ const EXTRA_COLORS = ["warning", "success", "info", "destructive"];
|
|
|
49
50
|
const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || "");
|
|
50
51
|
|
|
51
52
|
// Measures words in the map's own font (a canvas's measureText), so a
|
|
52
|
-
// topic's box fits its words exactly. Until the
|
|
53
|
-
// server
|
|
54
|
-
//
|
|
53
|
+
// topic's box fits its words exactly. Until the map is mounted, a guess —
|
|
54
|
+
// on the server AND in the browser's first render, which must lay the
|
|
55
|
+
// map out exactly as the server did: measured there, the topics landed
|
|
56
|
+
// elsewhere, the canvas's placeholder listed them in another order, and
|
|
57
|
+
// React threw a hydration error on every load of a saved map
|
|
58
|
+
// (makersclaw-store BLOCKS.md, mind-maps 0.1.0). Measured once mounted
|
|
59
|
+
// (the font of the element the map sits in, not the body's) and again
|
|
60
|
+
// when web fonts finish loading.
|
|
55
61
|
function useMeasure(hostRef) {
|
|
56
62
|
const [tick, setTick] = useState(0);
|
|
57
|
-
const r = useRef({ ctx: null, family: null, cache: new Map() });
|
|
63
|
+
const r = useRef({ ctx: null, family: null, cache: new Map(), mounted: false });
|
|
58
64
|
useEffect(() => {
|
|
59
65
|
let live = true;
|
|
66
|
+
r.current.mounted = true;
|
|
60
67
|
const again = () => {
|
|
61
68
|
if (!live) return;
|
|
62
69
|
r.current.cache.clear();
|
|
@@ -73,8 +80,8 @@ function useMeasure(hostRef) {
|
|
|
73
80
|
}, []);
|
|
74
81
|
return useCallback(
|
|
75
82
|
(text, style) => {
|
|
76
|
-
if (typeof document === "undefined") return estimateText(text, style);
|
|
77
83
|
const c = r.current;
|
|
84
|
+
if (typeof document === "undefined" || !c.mounted) return estimateText(text, style);
|
|
78
85
|
if (!c.ctx) c.ctx = document.createElement("canvas").getContext("2d");
|
|
79
86
|
if (!c.ctx) return estimateText(text, style);
|
|
80
87
|
if (!c.family) {
|
|
@@ -111,6 +118,15 @@ function useCoarsePointer() {
|
|
|
111
118
|
return coarse;
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/** The same map, as far as the view goes: the same centre topic and the
|
|
122
|
+
* same main branches (in any order). */
|
|
123
|
+
export function sameBranches(a, b) {
|
|
124
|
+
if (!a || !b || a.id !== b.id) return false;
|
|
125
|
+
const ids = new Set((a.children ?? []).map((c) => c.id));
|
|
126
|
+
const next = b.children ?? [];
|
|
127
|
+
return next.length === ids.size && next.every((c) => ids.has(c.id));
|
|
128
|
+
}
|
|
129
|
+
|
|
114
130
|
async function settle(fn, ...args) {
|
|
115
131
|
try {
|
|
116
132
|
const res = await fn(...args);
|
|
@@ -217,24 +233,47 @@ export function MindMap({
|
|
|
217
233
|
};
|
|
218
234
|
|
|
219
235
|
// Controlled: a `value` that is not our own echo replaces the map (and
|
|
220
|
-
// can be undone).
|
|
236
|
+
// can be undone). One that is another map — a record loaded, the
|
|
237
|
+
// agent's first map over the starter — is fitted to the view like a
|
|
238
|
+
// first one: until 0.5.0 it stayed at the old map's zoom and place, and
|
|
239
|
+
// mind-maps remounted the block to get a fit. The same map back from a
|
|
240
|
+
// save (its branches unchanged) keeps the view where it is.
|
|
221
241
|
const lastProp = useRef(value);
|
|
242
|
+
const [refit, setRefit] = useState(0);
|
|
222
243
|
useEffect(() => {
|
|
223
244
|
if (value === undefined || value === lastProp.current) return;
|
|
224
245
|
lastProp.current = value;
|
|
225
246
|
if (value === lastEmitted.current) return;
|
|
226
247
|
const next = normalizeMap(value);
|
|
227
|
-
|
|
248
|
+
const before = treeRef.current;
|
|
249
|
+
setHist({ past: [...histRef.current.past, before].slice(-HISTORY), future: [] });
|
|
228
250
|
setTree(next);
|
|
229
251
|
if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);
|
|
230
252
|
if (editingRef.current) setEditing(null);
|
|
253
|
+
if (!sameBranches(before, next)) setRefit((n) => n + 1);
|
|
231
254
|
}, [value, select]);
|
|
232
255
|
|
|
233
256
|
// ── what the canvas draws ────────────────────────────────────────────
|
|
234
257
|
const board = useMemo(() => mindMapToBoard(tree, { measure, colors }), [tree, measure, colors]);
|
|
235
258
|
const sizes = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);
|
|
259
|
+
const boardRef = useRef(board);
|
|
260
|
+
boardRef.current = board;
|
|
261
|
+
|
|
262
|
+
// Open a map fitted to the view, but never smaller than its words can be
|
|
263
|
+
// read: a map too big for that opens at READABLE_ZOOM on its centre
|
|
264
|
+
// topic (openingView). ⇧1 and "Fit to screen" still fit the whole map.
|
|
265
|
+
const fitOpen = useCallback(
|
|
266
|
+
(opts) => {
|
|
267
|
+
const api = canvas.current;
|
|
268
|
+
if (!api) return;
|
|
269
|
+
const pane = boxRef.current?.querySelector(".react-flow") ?? boxRef.current;
|
|
270
|
+
api.fitView({ ...opts, ...openingView(boardRef.current.nodes, { width: pane?.clientWidth, height: pane?.clientHeight, rootId: treeRef.current.id }) });
|
|
271
|
+
},
|
|
272
|
+
[canvas],
|
|
273
|
+
);
|
|
236
274
|
|
|
237
|
-
// Fit the map in view once its words are measured
|
|
275
|
+
// Fit the map in view once its words are measured, and again when a
|
|
276
|
+
// `value` puts another map in its place (above). The canvas fits only
|
|
238
277
|
// on its own first mount, before the topics are sized in the map's font
|
|
239
278
|
// (and again when web fonts land), so without this the map opened off to
|
|
240
279
|
// the top-left of an empty-looking canvas (2026-09-24).
|
|
@@ -243,10 +282,10 @@ export function MindMap({
|
|
|
243
282
|
// `value` in an effect of its own, and fits against what it holds.
|
|
244
283
|
let raf = 0;
|
|
245
284
|
const t = setTimeout(() => {
|
|
246
|
-
raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() =>
|
|
285
|
+
raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => fitOpen({ duration: 0 }))));
|
|
247
286
|
}, 300);
|
|
248
287
|
return () => (clearTimeout(t), cancelAnimationFrame(raf));
|
|
249
|
-
}, [measure]);
|
|
288
|
+
}, [measure, refit, fitOpen]);
|
|
250
289
|
|
|
251
290
|
// The canvas pans with a plain drag (its hand tool): a mind map has
|
|
252
291
|
// nothing to lasso.
|
|
@@ -447,7 +486,7 @@ export function MindMap({
|
|
|
447
486
|
finishEdit();
|
|
448
487
|
commit(normalizeMap(map));
|
|
449
488
|
select(null);
|
|
450
|
-
setTimeout(() =>
|
|
489
|
+
setTimeout(() => fitOpen(), 80);
|
|
451
490
|
};
|
|
452
491
|
|
|
453
492
|
// ── announcements ────────────────────────────────────────────────────
|
|
@@ -901,7 +940,7 @@ export function MindMap({
|
|
|
901
940
|
emptyState={false}
|
|
902
941
|
templates={[]}
|
|
903
942
|
describeNode={describeTopic}
|
|
904
|
-
className=
|
|
943
|
+
className={String.raw`[&_.react-flow\_\_node]:transition-transform [&_.react-flow\_\_node]:duration-200 [&_.react-flow\_\_node]:ease-out motion-reduce:[&_.react-flow\_\_node]:transition-none`}
|
|
905
944
|
/>
|
|
906
945
|
</MindContext.Provider>
|
|
907
946
|
</div>
|
|
@@ -377,6 +377,39 @@ export const LEVELS = [
|
|
|
377
377
|
{ font: 14, weight: 400, line: 20, padX: 6, padY: 5, min: 44, max: 240, gap: 28 },
|
|
378
378
|
];
|
|
379
379
|
export const levelOf = (depth) => LEVELS[Math.min(depth, LEVELS.length - 1)];
|
|
380
|
+
|
|
381
|
+
/** The smallest zoom a map opens at: its smallest words (14px, a main
|
|
382
|
+
* branch's and a subtopic's) drawn no smaller than the kit's smallest
|
|
383
|
+
* text, text-2xs (11px). 11 / 14 ≈ 0.79. */
|
|
384
|
+
export const READABLE_ZOOM = 11 / Math.min(...LEVELS.map((l) => l.font));
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* How a map opens in a view `width` × `height` px, as options for the
|
|
388
|
+
* canvas's fitView: the whole map when it fits at READABLE_ZOOM or more
|
|
389
|
+
* (→ {}), else READABLE_ZOOM centred on the centre topic `rootId`. Until
|
|
390
|
+
* 0.5.1 a big map opened fitted whole whatever the zoom: 31% on a phone,
|
|
391
|
+
* its words unreadable. `nodes` are the board's ({ position, width,
|
|
392
|
+
* height }); `padding` is the fit's share of the view (the canvas's 0.2),
|
|
393
|
+
* split as React Flow splits it.
|
|
394
|
+
*/
|
|
395
|
+
export function openingView(nodes, { width, height, rootId, padding = 0.2 } = {}) {
|
|
396
|
+
if (!(width > 0 && height > 0) || !nodes?.length || rootId == null) return {};
|
|
397
|
+
let x1 = Infinity;
|
|
398
|
+
let y1 = Infinity;
|
|
399
|
+
let x2 = -Infinity;
|
|
400
|
+
let y2 = -Infinity;
|
|
401
|
+
for (const n of nodes) {
|
|
402
|
+
const x = n.position?.x ?? 0;
|
|
403
|
+
const y = n.position?.y ?? 0;
|
|
404
|
+
x1 = Math.min(x1, x);
|
|
405
|
+
y1 = Math.min(y1, y);
|
|
406
|
+
x2 = Math.max(x2, x + (n.width ?? 0));
|
|
407
|
+
y2 = Math.max(y2, y + (n.height ?? 0));
|
|
408
|
+
}
|
|
409
|
+
const room = (v) => v - 2 * Math.floor((v - v / (1 + padding)) / 2);
|
|
410
|
+
const fit = Math.min(room(width) / Math.max(1, x2 - x1), room(height) / Math.max(1, y2 - y1));
|
|
411
|
+
return fit >= READABLE_ZOOM ? {} : { nodes: [{ id: rootId }], minZoom: READABLE_ZOOM, maxZoom: READABLE_ZOOM };
|
|
412
|
+
}
|
|
380
413
|
const SIBLING_GAP = [0, 20, 8];
|
|
381
414
|
const siblingGap = (depth) => SIBLING_GAP[Math.min(depth, SIBLING_GAP.length - 1)];
|
|
382
415
|
/** Room left under a subtopic for its coloured line. */
|
|
@@ -485,7 +485,7 @@ export function OrgChart({
|
|
|
485
485
|
emptyState={false}
|
|
486
486
|
templates={[]}
|
|
487
487
|
describeNode={describePerson}
|
|
488
|
-
className=
|
|
488
|
+
className={String.raw`[&_.react-flow\_\_node]:transition-transform [&_.react-flow\_\_node]:duration-200 [&_.react-flow\_\_node]:ease-out motion-reduce:[&_.react-flow\_\_node]:transition-none`}
|
|
489
489
|
/>
|
|
490
490
|
</OrgContext.Provider>
|
|
491
491
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// viewer, a sheet on a narrow one (pdf-viewer.jsx decides). A click on an
|
|
6
6
|
// entry takes the pages to it; its note is edited in place.
|
|
7
7
|
import { useEffect, useRef, useState } from "react";
|
|
8
|
-
import { Button, Icon, Menu, Textarea, cx } from "../../ui";
|
|
8
|
+
import { Button, Icon, Menu, Textarea, cx, toLocalDate } from "../../ui";
|
|
9
9
|
import { sortHighlights } from "./helpers";
|
|
10
10
|
|
|
11
11
|
/** The colours a highlight can take, as token names (never a colour). */
|
|
@@ -188,8 +188,9 @@ function NoteItem({ h, active, editing, readOnly, onJump, onEdit, onSaveNote, on
|
|
|
188
188
|
);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// A day alone ("2026-09-28") is that day in the reader's zone, not UTC's.
|
|
191
192
|
function when(v) {
|
|
192
|
-
const d =
|
|
193
|
-
if (
|
|
193
|
+
const d = toLocalDate(v);
|
|
194
|
+
if (!d) return String(v);
|
|
194
195
|
return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" }).format(d);
|
|
195
196
|
}
|
|
@@ -31,7 +31,7 @@ A note in `notes`:
|
|
|
31
31
|
id: "n-204",
|
|
32
32
|
author: { id: "u-lena", name: "Lena Fischer", src: "https://…/lena.jpg" },
|
|
33
33
|
body: [ /* the doc onAdd gave you (BlockNote JSON) */ ], // or a plain string
|
|
34
|
-
createdAt: "2026-09-23T09:12:00Z", // ISO string, Date or
|
|
34
|
+
createdAt: "2026-09-23T09:12:00Z", // ISO string, Date, ms, or a day ("2026-09-23": that day, never UTC's)
|
|
35
35
|
updatedAt: null, // set → "(edited)"
|
|
36
36
|
}
|
|
37
37
|
```
|