@tristan2828/ui-foundation 4.59.0 → 4.61.0
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/README.md +1 -1
- package/conventions/docs/add-an-entity.md +30 -11
- package/conventions/docs/cell-patterns.md +13 -10
- package/dist/components/app/entity-view-print.d.ts +24 -0
- package/dist/components/app/entity-view-print.js +51 -0
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +16 -2
- package/dist/components/app/markdown.d.ts +12 -1
- package/dist/components/app/markdown.js +78 -8
- package/dist/components/app/milkdown-editor.js +9 -3
- package/dist/components/app/print-context.d.ts +3 -0
- package/dist/components/app/print-context.js +10 -0
- package/dist/components/app/rich-text-tasks.d.ts +8 -0
- package/dist/components/app/rich-text-tasks.js +72 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/markdown-tasks.d.ts +19 -0
- package/dist/lib/markdown-tasks.js +48 -0
- package/package.json +1 -1
- package/styles/index.css +81 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission`. For a record opened in a dialog over its list (`docs/foundation/add-an-entity.md`): `RecordDialogRoute`, `RecordLink`, `useOpenRecord`, `useRecordDialog`, `recordDialogState` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`, printing its notes and `usePrinting`), `EntityForm`, `Markdown` (with `countTasks`, `toggleTaskAt`), `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission`. For a record opened in a dialog over its list (`docs/foundation/add-an-entity.md`): `RecordDialogRoute`, `RecordLink`, `useOpenRecord`, `useRecordDialog`, `recordDialogState` |
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
@@ -4,22 +4,22 @@
|
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
6
|
- [The reference files](#the-reference-files) · lines 38–60
|
|
7
|
-
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 62–
|
|
7
|
+
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 62–743
|
|
8
8
|
- Step 1: Add `<Entity>` to `openapi.yaml` · lines 100–107
|
|
9
9
|
- Step 2: `npm run gen:api` · lines 108–109
|
|
10
10
|
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 110–121
|
|
11
11
|
- Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 122–131
|
|
12
12
|
- Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 132–136
|
|
13
|
-
- Step 6: Copy the reference files, one for one, not just the two screens · lines 137–
|
|
14
|
-
- Step 7: Register routes · lines
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [Opening a record over its list](#opening-a-record-over-its-list) · lines
|
|
19
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
20
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
21
|
-
- [Deleting](#deleting) · lines
|
|
22
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
13
|
+
- Step 6: Copy the reference files, one for one, not just the two screens · lines 137–632
|
|
14
|
+
- Step 7: Register routes · lines 633–646
|
|
15
|
+
- Step 8: Add Playwright specs for all three screens · lines 647–725
|
|
16
|
+
- Step 9: Register the nav entry and route names · lines 726–740
|
|
17
|
+
- Step 10: `npm run verify`. · lines 741–743
|
|
18
|
+
- [Opening a record over its list](#opening-a-record-over-its-list) · lines 745–801
|
|
19
|
+
- [What not to copy](#what-not-to-copy) · lines 803–829
|
|
20
|
+
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 831–848
|
|
21
|
+
- [Deleting](#deleting) · lines 850–879
|
|
22
|
+
- [Fields only a script writes](#fields-only-a-script-writes) · lines 881–902
|
|
23
23
|
|
|
24
24
|
<!-- /index -->
|
|
25
25
|
|
|
@@ -488,6 +488,25 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
488
488
|
`headingLevel={2}` for a `RichTextEditor` on a form straight under
|
|
489
489
|
the page's `<h1>`. Text that's imported or pasted from another
|
|
490
490
|
tool (often starting at `##`) also gets `headingFrom="text"`.
|
|
491
|
+
- **A checklist kept in the text** (`- [ ] item`, `- [x] done`) is
|
|
492
|
+
ticked where it's read with `<Markdown onTasksChange={save}>`: each
|
|
493
|
+
box a checkbox named for its item, and a tick hands over the whole
|
|
494
|
+
text with only that box flipped, for the field's save
|
|
495
|
+
(`useEdit<Entity>Field`). Without the prop the boxes are pictures
|
|
496
|
+
and the text stays read-only. A list's "2/5" column counts them
|
|
497
|
+
with `countTasks(text)` (`{ done, total }`; `total` 0 for none).
|
|
498
|
+
- **Notes worth taking on paper** (a visit's prep and questions, a
|
|
499
|
+
guide, a checklist to tick by hand): mark each of those sections
|
|
500
|
+
`print: true`. The header gets a Print notes button (`printLabel`
|
|
501
|
+
renames it) that prints the marked sections alone, under the title
|
|
502
|
+
and subtitle: no rail, no shell, no buttons, light paper in either
|
|
503
|
+
theme, headings and bullets as written, nothing folded, tables
|
|
504
|
+
ruled with room in their empty cells. `print: { answerSpace: true }`
|
|
505
|
+
also leaves space under each item of the section's numbered lists,
|
|
506
|
+
for questions answered by hand. A section's own content that should
|
|
507
|
+
print differently (a list of sub-items as plain text) reads
|
|
508
|
+
`usePrinting()`, true in the printed copy. The browser's own print
|
|
509
|
+
(Ctrl+P) still prints the whole page.
|
|
491
510
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
492
511
|
names**, each a control that saves on its own (cell pattern 17,
|
|
493
512
|
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
@@ -26,15 +26,15 @@
|
|
|
26
26
|
- [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
|
|
27
27
|
- [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
|
|
28
28
|
- [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
|
|
29
|
-
- [18. Editable value](#18-editable-value) · lines 762–
|
|
30
|
-
- [A list of sub-items](#a-list-of-sub-items) · lines
|
|
31
|
-
- [In a table's rows](#in-a-tables-rows) · lines
|
|
32
|
-
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines
|
|
33
|
-
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines
|
|
34
|
-
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines
|
|
35
|
-
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines
|
|
36
|
-
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines
|
|
37
|
-
- [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines
|
|
29
|
+
- [18. Editable value](#18-editable-value) · lines 762–1221
|
|
30
|
+
- [A list of sub-items](#a-list-of-sub-items) · lines 1037–1122
|
|
31
|
+
- [In a table's rows](#in-a-tables-rows) · lines 1124–1209
|
|
32
|
+
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1211–1221
|
|
33
|
+
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1223–1289
|
|
34
|
+
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1291–1355
|
|
35
|
+
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1357–1425
|
|
36
|
+
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1427–1455
|
|
37
|
+
- [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1457–1533
|
|
38
38
|
|
|
39
39
|
<!-- /index -->
|
|
40
40
|
|
|
@@ -1013,7 +1013,10 @@ const price = editInPlace({
|
|
|
1013
1013
|
and Outdent for list items, where there is no Tab. A field with only the
|
|
1014
1014
|
floating toolbar (`inline` notes) gets the same two in a small "Editor
|
|
1015
1015
|
tools" strip over its corner while it has focus. A new checklist item
|
|
1016
|
-
shows a "To-do" placeholder and the caret until you type.
|
|
1016
|
+
shows a "To-do" placeholder and the caret until you type. `[] ` (or
|
|
1017
|
+
`[x] `) at a line's start makes it a checklist item, a bulleted item
|
|
1018
|
+
too; Enter in one makes the next, its box unticked, and Enter in an
|
|
1019
|
+
empty one ends the list, as in a bulleted list.
|
|
1017
1020
|
`toolbar={false}` leaves out the toolbars and the slash menu where a
|
|
1018
1021
|
value is short and the shortcuts are enough; `toolbar="floating"` keeps
|
|
1019
1022
|
the floating one and the slash menu without the bar above (an `inline`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** What a section marked to print gives the printed copy. */
|
|
3
|
+
export type EntityViewPrintSection = {
|
|
4
|
+
title: string;
|
|
5
|
+
/** The section's content, or its fields as label/value rows. */
|
|
6
|
+
content?: ReactNode;
|
|
7
|
+
fields?: {
|
|
8
|
+
label: string;
|
|
9
|
+
value: ReactNode;
|
|
10
|
+
}[];
|
|
11
|
+
emptyLabel?: string;
|
|
12
|
+
answerSpace: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The header's Print notes button and, while it prints, the printed copy.
|
|
16
|
+
* The copy mounts, then the browser's print dialog opens on it; it goes
|
|
17
|
+
* when the dialog closes (`afterprint`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function PrintNotes({ label, title, subtitle, sections, }: {
|
|
20
|
+
label: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
subtitle?: ReactNode;
|
|
23
|
+
sections: EntityViewPrintSection[];
|
|
24
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// A record's notes on paper: EntityView's Print notes button and the copy
|
|
3
|
+
// it prints. Sections marked `print` (the long text: details, prep,
|
|
4
|
+
// questions) are printed under the record's title and subtitle, and
|
|
5
|
+
// nothing else: no sidebar, no header bar, no rail, no buttons.
|
|
6
|
+
//
|
|
7
|
+
// The copy is rendered only while printing, into <body> (a portal, so it
|
|
8
|
+
// sits beside the app rather than inside its layout), and styles/index.css
|
|
9
|
+
// "Printed notes" hides everything else on paper and keeps it light even
|
|
10
|
+
// in the dark theme. On screen it never shows. Its content is each
|
|
11
|
+
// section's own markup, read-only (PrintingContext): <Markdown> prints
|
|
12
|
+
// whole, unfolded, with tables ruled and room in their empty cells to
|
|
13
|
+
// write. A section marked `print: { answerSpace: true }` leaves space
|
|
14
|
+
// under each item of its numbered lists, for questions answered by hand.
|
|
15
|
+
//
|
|
16
|
+
// The browser's own print (Ctrl+P) still prints the page as it is; only
|
|
17
|
+
// the button prints the notes alone.
|
|
18
|
+
import { useEffect, useState } from 'react';
|
|
19
|
+
import { createPortal } from 'react-dom';
|
|
20
|
+
import { PrinterIcon } from 'lucide-react';
|
|
21
|
+
import { Button } from '../ui/button.js';
|
|
22
|
+
import { PrintingContext } from './print-context.js';
|
|
23
|
+
function isEmpty(value) {
|
|
24
|
+
return value === null || value === undefined || value === false || value === '' || (Array.isArray(value) && value.length === 0);
|
|
25
|
+
}
|
|
26
|
+
function PrintedSection({ section }) {
|
|
27
|
+
return (_jsxs("section", { "data-answer-space": section.answerSpace ? '' : undefined, className: "flex flex-col gap-3", children: [_jsx("h2", { className: "border-b border-border pb-1 type-label tracking-[0.08em] text-muted-foreground uppercase", children: section.title }), section.fields ? (_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 type-body", children: section.fields.map((field) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 break-words text-foreground", children: isEmpty(field.value) ? '—' : field.value })] }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: isEmpty(section.content) ? _jsx("span", { className: "text-muted-foreground", children: section.emptyLabel ?? 'Not set' }) : section.content }))] }));
|
|
28
|
+
}
|
|
29
|
+
function PrintedNotes({ title, subtitle, sections }) {
|
|
30
|
+
return createPortal(_jsx(PrintingContext.Provider, { value: true, children: _jsxs("article", { "data-print-notes": "", "data-slot": "entity-view-print", className: "hidden flex-col gap-6 bg-background text-foreground print:flex", children: [_jsxs("header", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "type-heading-1 break-words", children: title }), subtitle && _jsx("div", { className: "type-body break-words text-muted-foreground", children: subtitle })] }), sections.map((section) => (_jsx(PrintedSection, { section: section }, section.title)))] }) }), document.body);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The header's Print notes button and, while it prints, the printed copy.
|
|
34
|
+
* The copy mounts, then the browser's print dialog opens on it; it goes
|
|
35
|
+
* when the dialog closes (`afterprint`).
|
|
36
|
+
*/
|
|
37
|
+
export function PrintNotes({ label, title, subtitle, sections, }) {
|
|
38
|
+
// How many times it's been pressed since the copy last went: a press
|
|
39
|
+
// while one is up (a browser that never said the dialog closed) prints
|
|
40
|
+
// it again rather than doing nothing.
|
|
41
|
+
const [printing, setPrinting] = useState(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (printing === 0)
|
|
44
|
+
return;
|
|
45
|
+
const done = () => setPrinting(0);
|
|
46
|
+
window.addEventListener('afterprint', done);
|
|
47
|
+
window.print();
|
|
48
|
+
return () => window.removeEventListener('afterprint', done);
|
|
49
|
+
}, [printing]);
|
|
50
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", onClick: () => setPrinting((count) => count + 1), children: [_jsx(PrinterIcon, {}), label] }), printing > 0 && _jsx(PrintedNotes, { title: title, subtitle: subtitle, sections: sections })] }));
|
|
51
|
+
}
|
|
@@ -44,6 +44,17 @@ export type EntityViewSection = ({
|
|
|
44
44
|
* long text and long lists. The `column` layout ignores it.
|
|
45
45
|
*/
|
|
46
46
|
placement?: EntityViewPlacement;
|
|
47
|
+
/**
|
|
48
|
+
* The section is part of the record's printed notes: marking any one
|
|
49
|
+
* adds a Print notes button to the header, which prints the marked
|
|
50
|
+
* sections alone (their content as shown, read-only, nothing folded)
|
|
51
|
+
* under the title and subtitle. `{ answerSpace: true }` also leaves
|
|
52
|
+
* room under each item of the section's numbered lists, to write an
|
|
53
|
+
* answer by hand (a visit's questions).
|
|
54
|
+
*/
|
|
55
|
+
print?: boolean | {
|
|
56
|
+
answerSpace?: boolean;
|
|
57
|
+
};
|
|
47
58
|
};
|
|
48
59
|
export type EntityViewPlacement = 'main' | 'rail';
|
|
49
60
|
/**
|
|
@@ -115,6 +126,8 @@ export type EntityViewProps = {
|
|
|
115
126
|
* ignores it.
|
|
116
127
|
*/
|
|
117
128
|
mobileOrder?: EntityViewMobileOrder;
|
|
129
|
+
/** The Print notes button's words, when a section is marked `print`. Defaults to "Print notes". */
|
|
130
|
+
printLabel?: string;
|
|
118
131
|
/** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
|
|
119
132
|
railLabel?: string;
|
|
120
133
|
isLoading: boolean;
|
|
@@ -127,4 +140,4 @@ export type EntityViewProps = {
|
|
|
127
140
|
label: string;
|
|
128
141
|
};
|
|
129
142
|
};
|
|
130
|
-
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
143
|
+
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, printLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -40,6 +40,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
40
40
|
// to the dialog's bar (Open full page, Close), and not found's way out
|
|
41
41
|
// closes the dialog.
|
|
42
42
|
//
|
|
43
|
+
// Printing the notes (entity-view-print.tsx): sections marked `print`
|
|
44
|
+
// (the long text: details, prep, questions) give the header a Print notes
|
|
45
|
+
// button, which prints those sections alone under the title and
|
|
46
|
+
// subtitle, with no rail, shell or buttons, on light paper.
|
|
47
|
+
//
|
|
43
48
|
// Two layouts. `column` (the default): every section in one column capped
|
|
44
49
|
// at max-w-4xl. `rail`, for a record with long content: the page takes the
|
|
45
50
|
// content area's whole width, sections placed `rail` go in a narrow column
|
|
@@ -60,6 +65,7 @@ import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
|
60
65
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
61
66
|
import { Skeleton } from '../ui/skeleton.js';
|
|
62
67
|
import { useDocumentTitle } from '../../hooks/use-document-title.js';
|
|
68
|
+
import { PrintNotes } from './entity-view-print.js';
|
|
63
69
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
64
70
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
65
71
|
import { EditableList } from './editable-list.js';
|
|
@@ -153,7 +159,7 @@ function Rail({ label, sections }) {
|
|
|
153
159
|
const [ref, fits] = useFitsWindow();
|
|
154
160
|
return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
|
|
155
161
|
}
|
|
156
|
-
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, isLoading, error, onRetry, back, }) {
|
|
162
|
+
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, printLabel = 'Print notes', isLoading, error, onRetry, back, }) {
|
|
157
163
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
158
164
|
// while a field is open (or one is always editable).
|
|
159
165
|
const [store] = useState(() => new EditInPlaceStore());
|
|
@@ -171,8 +177,16 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
|
|
|
171
177
|
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
172
178
|
if (isLoading)
|
|
173
179
|
return _jsx(EntityViewSkeleton, { layout: layout, mobileOrder: mobileOrder });
|
|
180
|
+
const printSections = sections
|
|
181
|
+
.filter((section) => section.print)
|
|
182
|
+
.map((section) => ({
|
|
183
|
+
title: section.title,
|
|
184
|
+
...('fields' in section ? { fields: section.fields } : { content: section.content, emptyLabel: section.emptyLabel }),
|
|
185
|
+
answerSpace: typeof section.print === 'object' && section.print.answerSpace === true,
|
|
186
|
+
}));
|
|
187
|
+
const printButton = printSections.length > 0 && (_jsx(PrintNotes, { label: printLabel, title: title, subtitle: subtitle, sections: printSections }));
|
|
174
188
|
const railSections = sections.filter((section) => section.placement === 'rail');
|
|
175
189
|
const mainSections = sections.filter((section) => section.placement !== 'rail');
|
|
176
190
|
const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
|
|
177
|
-
return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), !dialog && _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { id: dialog?.titleId, className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions &&
|
|
191
|
+
return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), !dialog && _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { id: dialog?.titleId, className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), (actions || printButton) && (_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: [printButton, actions] }))] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
|
|
178
192
|
}
|
|
@@ -33,6 +33,17 @@ export type MarkdownProps = {
|
|
|
33
33
|
* what was folded is remembered for when it's on again. Default `true`.
|
|
34
34
|
*/
|
|
35
35
|
foldable?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Lets the text's task items be ticked where they're read: each
|
|
38
|
+
* `- [ ]` and `- [x]` box becomes a checkbox, named for its item, and
|
|
39
|
+
* ticking one calls this with the whole text, that item's box flipped
|
|
40
|
+
* and nothing else changed, for the app to save. The tick shows at
|
|
41
|
+
* once and a second one builds on it, until `children` changes (pass
|
|
42
|
+
* the saved text back in). Return the save's promise: if it rejects,
|
|
43
|
+
* the boxes go back to `children`. Without it (the default) the boxes
|
|
44
|
+
* are pictures and the text stays read-only.
|
|
45
|
+
*/
|
|
46
|
+
onTasksChange?: (markdown: string) => void | Promise<unknown>;
|
|
36
47
|
className?: string;
|
|
37
48
|
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
38
|
-
export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
49
|
+
export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -44,18 +44,31 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
44
44
|
// it that folds its section away (src/lib/markdown-fold.ts), remembered on
|
|
45
45
|
// this device under the key and shared with an editor or NotesOutline
|
|
46
46
|
// given the same one. The text itself is never changed.
|
|
47
|
-
|
|
47
|
+
//
|
|
48
|
+
// Printed (EntityView's Print notes, PrintingContext), it's the whole
|
|
49
|
+
// text: nothing folded, no fold buttons, task boxes as glyphs.
|
|
50
|
+
//
|
|
51
|
+
// A task item (`- [ ] to do`, `- [x] done`) shows its box as a glyph
|
|
52
|
+
// with its state in words: read-only text has nothing to tick. With
|
|
53
|
+
// `onTasksChange`, each box is a real checkbox, named for its item's
|
|
54
|
+
// words, that ticks or unticks the item in the text and hands the whole
|
|
55
|
+
// new text over to save (src/lib/markdown-tasks.ts): only that item's
|
|
56
|
+
// `[ ]` or `[x]` changes, the rest stays exactly as written.
|
|
57
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
48
58
|
import ReactMarkdown, {} from 'react-markdown';
|
|
49
59
|
import remarkGfm from 'remark-gfm';
|
|
50
60
|
import { cn } from 'cn';
|
|
51
61
|
import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
|
|
62
|
+
import { Checkbox } from '../ui/checkbox.js';
|
|
52
63
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
|
|
53
64
|
import { useFolds } from '../../hooks/fold-store.js';
|
|
54
65
|
import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
|
|
55
66
|
import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
|
|
56
67
|
import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
|
|
68
|
+
import { toggleTaskAt } from '../../lib/markdown-tasks.js';
|
|
57
69
|
import { textTopHeading } from '../../lib/markdown-top-heading.js';
|
|
58
70
|
import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
|
|
71
|
+
import { usePrinting } from './print-context.js';
|
|
59
72
|
import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
|
|
60
73
|
const REMARK_PLUGINS = [remarkGfm, remarkCallouts, remarkHighlights];
|
|
61
74
|
const HIGHLIGHT_WASH = {
|
|
@@ -92,6 +105,34 @@ function NotesTable({ hidden, ...props }) {
|
|
|
92
105
|
// Folded away, the box goes with the table.
|
|
93
106
|
containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
|
|
94
107
|
}
|
|
108
|
+
// Ticks a task item by where it starts in the text: given only with
|
|
109
|
+
// `onTasksChange`. Each task item hands its box its own start and words.
|
|
110
|
+
const TaskToggleContext = createContext(null);
|
|
111
|
+
const TaskItemContext = createContext(null);
|
|
112
|
+
// An item's own words, for its checkbox's name: its nested lists left
|
|
113
|
+
// out, so a parent is named for itself and not its children too.
|
|
114
|
+
function itemText(node) {
|
|
115
|
+
if (!node)
|
|
116
|
+
return '';
|
|
117
|
+
if (node.type === 'text')
|
|
118
|
+
return node.value ?? '';
|
|
119
|
+
if (node.tagName === 'ul' || node.tagName === 'ol')
|
|
120
|
+
return '';
|
|
121
|
+
return (node.children ?? []).map(itemText).join('');
|
|
122
|
+
}
|
|
123
|
+
// A task item's box: a real checkbox when ticking is on (named for its
|
|
124
|
+
// item, so a screen reader hears "Buy milk, checkbox, checked"), the
|
|
125
|
+
// glyph otherwise.
|
|
126
|
+
function TaskBox({ checked }) {
|
|
127
|
+
const toggle = useContext(TaskToggleContext);
|
|
128
|
+
const item = useContext(TaskItemContext);
|
|
129
|
+
if (toggle && item)
|
|
130
|
+
return (_jsx(Checkbox, { checked: checked, onCheckedChange: () => toggle(item.offset),
|
|
131
|
+
// Ticking is all it does: a click never reaches whatever the text
|
|
132
|
+
// sits in (a card that opens the record, a field that edits).
|
|
133
|
+
onClick: (event) => event.stopPropagation(), "aria-label": item.name || (checked ? 'Done' : 'Not done'), "data-slot": "task-box", className: "mr-2 inline-flex align-[-0.1875rem]" }));
|
|
134
|
+
return checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-muted-foreground" }));
|
|
135
|
+
}
|
|
95
136
|
// Folds a section by its heading's id: given only with `foldKey`.
|
|
96
137
|
const FoldContext = createContext(null);
|
|
97
138
|
// A heading by its Markdown level: the element the page's outline gives
|
|
@@ -129,11 +170,22 @@ const COMPONENTS = {
|
|
|
129
170
|
ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
|
|
130
171
|
li: (props) => {
|
|
131
172
|
const { className, ...rest } = omitNode(props);
|
|
132
|
-
|
|
173
|
+
const task = className?.includes('task-list-item');
|
|
174
|
+
// A task item's box hangs where a bullet would; a list under it starts
|
|
175
|
+
// at its words, and so does a loose item's next paragraph.
|
|
176
|
+
const item = (_jsx("li", { ...rest, className: cn(task && '-ml-6 list-none [&>ol]:pl-12 [&>p:not(:first-child)]:pl-6 [&>ul]:pl-12') }));
|
|
177
|
+
const node = props.node;
|
|
178
|
+
const offset = node?.position?.start.offset;
|
|
179
|
+
if (!task || offset === undefined)
|
|
180
|
+
return item;
|
|
181
|
+
// A loose item's words are its first paragraph; what's under it isn't its name.
|
|
182
|
+
const first = node?.children?.find((child) => child.tagName === 'p');
|
|
183
|
+
const name = itemText(first ?? node).replace(/\s+/g, ' ').trim();
|
|
184
|
+
return _jsx(TaskItemContext.Provider, { value: { offset, name }, children: item });
|
|
133
185
|
},
|
|
134
|
-
// A GitHub task-list box (`- [x] done`)
|
|
135
|
-
//
|
|
136
|
-
input: ({ checked }) =>
|
|
186
|
+
// A GitHub task-list box (`- [x] done`): a glyph, or a checkbox with
|
|
187
|
+
// `onTasksChange` (TaskBox).
|
|
188
|
+
input: ({ checked }) => _jsx(TaskBox, { checked: Boolean(checked) }),
|
|
137
189
|
blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
|
|
138
190
|
hr: ({ hidden }) => _jsx("hr", { hidden: hidden, className: PROSE.rule }),
|
|
139
191
|
// Highlighted words (remarkHighlights marks them): the colour's wash.
|
|
@@ -184,12 +236,30 @@ function componentsFor(top, textTop) {
|
|
|
184
236
|
}
|
|
185
237
|
return components;
|
|
186
238
|
}
|
|
187
|
-
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, className, ...rest }) {
|
|
188
|
-
|
|
239
|
+
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, className, ...rest }) {
|
|
240
|
+
// On paper (EntityView's Print notes): every heading plain and open,
|
|
241
|
+
// every task box a picture.
|
|
242
|
+
const printing = usePrinting();
|
|
243
|
+
const onTasksChange = printing ? undefined : onTasksChangeProp;
|
|
244
|
+
const foldKey = foldable && !printing ? foldKeyProp : undefined;
|
|
189
245
|
const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
|
|
190
246
|
const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
|
|
191
247
|
const [folded, setFolded] = useFolds(foldKey);
|
|
192
248
|
const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
|
|
193
249
|
const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
|
|
194
|
-
|
|
250
|
+
// Ticks not yet back as `children`: shown, and built on by the next
|
|
251
|
+
// tick, so two quick ticks on a slow save both land. Kept while
|
|
252
|
+
// `children` is the text they started from or one of their own saves
|
|
253
|
+
// coming back; dropped when it's anything else, or a save fails.
|
|
254
|
+
const [pending, setPending] = useState(null);
|
|
255
|
+
const live = pending && (pending.base === children || pending.sent.includes(children)) ? pending : null;
|
|
256
|
+
const shown = live ? live.text : children;
|
|
257
|
+
const tickTask = useCallback((offset) => {
|
|
258
|
+
const next = toggleTaskAt(shown, offset);
|
|
259
|
+
setPending({ base: live ? live.base : children, text: next, sent: [...(live ? live.sent : []), next] });
|
|
260
|
+
const saving = onTasksChange?.(next);
|
|
261
|
+
if (saving && typeof saving.then === 'function')
|
|
262
|
+
saving.then(undefined, () => setPending((current) => (current?.sent.includes(next) ? null : current)));
|
|
263
|
+
}, [onTasksChange, children, shown, live]);
|
|
264
|
+
return (_jsx("div", { "data-slot": "markdown", "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
|
|
195
265
|
}
|
|
@@ -30,9 +30,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
30
30
|
// copy out of the editor is Markdown.
|
|
31
31
|
// - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
|
|
32
32
|
// addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
|
|
33
|
-
// in.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// in. `[] ` at a line's start makes a task item, and Enter in one the
|
|
34
|
+
// next, unticked (rich-text-tasks.ts). Ctrl/Cmd+Enter stays the field's
|
|
35
|
+
// save, in a table too. Notion's block keys (Ctrl+Shift+1 for a
|
|
36
|
+
// heading, +5 for bullets, …: rich-text-shortcuts.ts).
|
|
36
37
|
// - A placeholder in an empty document, and a count of the Markdown's
|
|
37
38
|
// length as it nears `maxLength`.
|
|
38
39
|
// - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
|
|
@@ -84,6 +85,7 @@ import { tableAt, toggleFormat } from './rich-text-formats.js';
|
|
|
84
85
|
import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
|
|
85
86
|
import { droppedFiles, imageUploadPlugin, pastedImages, uploadImages, uploadPlaceholders } from './rich-text-image-upload.js';
|
|
86
87
|
import { handleShortcut } from './rich-text-shortcuts.js';
|
|
88
|
+
import { splitTaskItem, taskInputRule } from './rich-text-tasks.js';
|
|
87
89
|
import { addRowAtEnd, handleTableKey, runTableAction } from './rich-text-table.js';
|
|
88
90
|
import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
|
|
89
91
|
import { RichTextSlashMenu } from './rich-text-slash-menu.js';
|
|
@@ -751,6 +753,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
751
753
|
return true;
|
|
752
754
|
if (handleTableKey(view.state, view.dispatch, event))
|
|
753
755
|
return true;
|
|
756
|
+
// Enter in a task item: the next one, unticked (rich-text-tasks.ts).
|
|
757
|
+
if (event.key === 'Enter' && plain && !event.isComposing && splitTaskItem(view.state, view.dispatch))
|
|
758
|
+
return true;
|
|
754
759
|
// Ctrl/Cmd+Enter in a table is the field's (a save), not
|
|
755
760
|
// Milkdown's "leave the table": taken here, so its keymap never
|
|
756
761
|
// sees it, and left to reach the field.
|
|
@@ -768,6 +773,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
768
773
|
})
|
|
769
774
|
.use(COMMONMARK)
|
|
770
775
|
.use(gfm)
|
|
776
|
+
.use(taskInputRule)
|
|
771
777
|
.use(calloutRemark)
|
|
772
778
|
.use(calloutSchema)
|
|
773
779
|
.use(highlightRemark)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Whether what's rendering is a printed copy (EntityView's Print notes,
|
|
2
|
+
// entity-view-print.tsx). Content that has a screen-only side reads it:
|
|
3
|
+
// <Markdown> keeps its headings plain and unfolded and its task boxes as
|
|
4
|
+
// pictures, since paper has nothing to click.
|
|
5
|
+
import { createContext, useContext } from 'react';
|
|
6
|
+
export const PrintingContext = createContext(false);
|
|
7
|
+
/** True inside a printed copy of a record's notes. */
|
|
8
|
+
export function usePrinting() {
|
|
9
|
+
return useContext(PrintingContext);
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
|
|
2
|
+
export declare const taskInputRule: import("@milkdown/utils").$InputRule;
|
|
3
|
+
/**
|
|
4
|
+
* Enter in a task item with words in it: the item split there, and the
|
|
5
|
+
* new one's box unticked. False anywhere else (an empty task item
|
|
6
|
+
* included), for the editor's own Enter.
|
|
7
|
+
*/
|
|
8
|
+
export declare function splitTaskItem(state: EditorState, dispatch: (tr: Transaction) => void): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Task lists in the rich-text editor, typed rather than picked:
|
|
2
|
+
// - `[] ` (or `[ ] `, `[x] `) at a line's start makes it a task item: a
|
|
3
|
+
// plain line becomes a new task list, a list item becomes a task item.
|
|
4
|
+
// GFM's own rule only knows `[ ] ` already inside a list item.
|
|
5
|
+
// - Enter in a task item makes the next one, its box unticked, whatever
|
|
6
|
+
// the item it came from. (ProseMirror's split copies the item, ticked
|
|
7
|
+
// box and all.) Enter in an empty one ends the list, as in a bulleted
|
|
8
|
+
// one: that's ProseMirror's own, left as it is.
|
|
9
|
+
import { InputRule } from '@milkdown/kit/prose/inputrules';
|
|
10
|
+
import { findWrapping } from '@milkdown/kit/prose/transform';
|
|
11
|
+
import { splitListItem } from '@milkdown/kit/prose/schema-list';
|
|
12
|
+
import { $inputRule } from '@milkdown/kit/utils';
|
|
13
|
+
const TASK_START = /^\[([ xX]?)\]\s$/;
|
|
14
|
+
export const taskInputRule = $inputRule(() => new InputRule(TASK_START, (state, match, start, end) => {
|
|
15
|
+
const { list_item: item, bullet_list: list } = state.schema.nodes;
|
|
16
|
+
const $start = state.doc.resolve(start);
|
|
17
|
+
const paragraph = $start.parent;
|
|
18
|
+
if (paragraph.type.name !== 'paragraph')
|
|
19
|
+
return null;
|
|
20
|
+
const checked = match[1].toLowerCase() === 'x';
|
|
21
|
+
// A list item's first line: the item becomes a task item.
|
|
22
|
+
if ($start.depth >= 2 && $start.node(-1).type === item && $start.index(-1) === 0) {
|
|
23
|
+
const node = $start.node(-1);
|
|
24
|
+
if (node.attrs.checked != null)
|
|
25
|
+
return null;
|
|
26
|
+
return state.tr.delete(start, end).setNodeMarkup($start.before(-1), undefined, { ...node.attrs, checked });
|
|
27
|
+
}
|
|
28
|
+
// A plain line: wrapped in a new list, its item a task item.
|
|
29
|
+
const tr = state.tr.delete(start, end);
|
|
30
|
+
const range = tr.doc.resolve(start).blockRange();
|
|
31
|
+
const wrapping = range && findWrapping(range, list);
|
|
32
|
+
if (!range || !wrapping)
|
|
33
|
+
return null;
|
|
34
|
+
tr.wrap(range, wrapping);
|
|
35
|
+
const itemPos = range.start + 1;
|
|
36
|
+
const made = tr.doc.nodeAt(itemPos);
|
|
37
|
+
if (made?.type !== item)
|
|
38
|
+
return null;
|
|
39
|
+
return tr.setNodeMarkup(itemPos, undefined, { ...made.attrs, checked });
|
|
40
|
+
}));
|
|
41
|
+
/**
|
|
42
|
+
* Enter in a task item with words in it: the item split there, and the
|
|
43
|
+
* new one's box unticked. False anywhere else (an empty task item
|
|
44
|
+
* included), for the editor's own Enter.
|
|
45
|
+
*/
|
|
46
|
+
export function splitTaskItem(state, dispatch) {
|
|
47
|
+
const { $from, empty } = state.selection;
|
|
48
|
+
if (!empty || $from.depth < 2 || $from.parent.type.name !== 'paragraph')
|
|
49
|
+
return false;
|
|
50
|
+
const node = $from.node(-1);
|
|
51
|
+
if (node.type.name !== 'list_item' || node.attrs.checked == null)
|
|
52
|
+
return false;
|
|
53
|
+
if ($from.parent.content.size === 0)
|
|
54
|
+
return false;
|
|
55
|
+
let split = null;
|
|
56
|
+
if (!splitListItem(node.type)(state, (tr) => (split = tr)))
|
|
57
|
+
return false;
|
|
58
|
+
const tr = split;
|
|
59
|
+
if (!tr)
|
|
60
|
+
return false;
|
|
61
|
+
const $to = tr.selection.$from;
|
|
62
|
+
for (let depth = $to.depth; depth > 0; depth--) {
|
|
63
|
+
const made = $to.node(depth);
|
|
64
|
+
if (made.type.name !== 'list_item')
|
|
65
|
+
continue;
|
|
66
|
+
if (made.attrs.checked)
|
|
67
|
+
tr.setNodeMarkup($to.before(depth), undefined, { ...made.attrs, checked: false });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
dispatch(tr.scrollIntoView());
|
|
71
|
+
return true;
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
|
13
13
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
14
14
|
export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
|
|
15
15
|
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewMobileOrder, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
16
|
+
export { usePrinting } from './components/app/print-context.js';
|
|
16
17
|
export { RecordDialogRoute, RecordLink, type RecordDialogRouteProps, type RecordLinkProps, } from './components/app/record-dialog.js';
|
|
17
18
|
export { recordDialogState, useOpenRecord, useRecordDialog, type RecordDialogBackground, type RecordDialogContextValue, type RecordDialogState, } from './components/app/record-dialog-context.js';
|
|
18
19
|
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
@@ -22,6 +23,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
22
23
|
export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
|
|
23
24
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
24
25
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
26
|
+
export { countTasks, toggleTaskAt, type TaskCount } from './lib/markdown-tasks.js';
|
|
25
27
|
export { NotesOutline, type NotesOutlineProps } from './components/app/notes-outline.js';
|
|
26
28
|
export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
|
|
27
29
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { byDay } from './lib/group-by-day.js';
|
|
|
17
17
|
export { EntityForm } from './components/app/entity-form.js';
|
|
18
18
|
export { KeyStats } from './components/app/key-stats.js';
|
|
19
19
|
export { EntityView, } from './components/app/entity-view.js';
|
|
20
|
+
export { usePrinting } from './components/app/print-context.js';
|
|
20
21
|
export { RecordDialogRoute, RecordLink, } from './components/app/record-dialog.js';
|
|
21
22
|
export { recordDialogState, useOpenRecord, useRecordDialog, } from './components/app/record-dialog-context.js';
|
|
22
23
|
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
@@ -26,6 +27,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
26
27
|
export { InlineCreate } from './components/app/inline-create.js';
|
|
27
28
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
28
29
|
export { Markdown } from './components/app/markdown.js';
|
|
30
|
+
export { countTasks, toggleTaskAt } from './lib/markdown-tasks.js';
|
|
29
31
|
export { NotesOutline } from './components/app/notes-outline.js';
|
|
30
32
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
31
33
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type TaskCount = {
|
|
2
|
+
/** Task items ticked (`- [x]`). */
|
|
3
|
+
done: number;
|
|
4
|
+
/** Every task item, ticked or not, nested ones included. */
|
|
5
|
+
total: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* How many task items the text has, and how many are ticked: nested
|
|
9
|
+
* items count, plain list items don't. `{ done: 0, total: 0 }` for text
|
|
10
|
+
* with none (or none at all), so a column can show nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function countTasks(markdown: string | null | undefined): TaskCount;
|
|
13
|
+
/**
|
|
14
|
+
* The text with the task item that starts at `offset` (its list marker's
|
|
15
|
+
* place in the text, as the parser gives it) ticked when it wasn't and
|
|
16
|
+
* unticked when it was. Everything else is left exactly as written. The
|
|
17
|
+
* text unchanged when no task item starts there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toggleTaskAt(markdown: string, offset: number): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A Markdown text's task items (GitHub's `- [ ] to do`, `- [x] done`):
|
|
2
|
+
// counted, for a table's "2/5" column (countTasks), and one ticked or
|
|
3
|
+
// unticked in the source, for <Markdown>'s `onTasksChange` (toggleTaskAt).
|
|
4
|
+
// Parsed as <Markdown> and the rich-text editor parse it (remark, GFM), so
|
|
5
|
+
// a `- [ ]` inside a code block, or a `[ ]` that isn't at an item's start,
|
|
6
|
+
// is never a task.
|
|
7
|
+
import remarkGfm from 'remark-gfm';
|
|
8
|
+
import remarkParse from 'remark-parse';
|
|
9
|
+
import { unified } from 'unified';
|
|
10
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
11
|
+
/**
|
|
12
|
+
* How many task items the text has, and how many are ticked: nested
|
|
13
|
+
* items count, plain list items don't. `{ done: 0, total: 0 }` for text
|
|
14
|
+
* with none (or none at all), so a column can show nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function countTasks(markdown) {
|
|
17
|
+
const count = { done: 0, total: 0 };
|
|
18
|
+
if (!markdown)
|
|
19
|
+
return count;
|
|
20
|
+
const visit = (node) => {
|
|
21
|
+
if (node.type === 'listItem' && typeof node.checked === 'boolean') {
|
|
22
|
+
count.total += 1;
|
|
23
|
+
if (node.checked)
|
|
24
|
+
count.done += 1;
|
|
25
|
+
}
|
|
26
|
+
for (const child of node.children ?? [])
|
|
27
|
+
visit(child);
|
|
28
|
+
};
|
|
29
|
+
visit(parser.parse(markdown));
|
|
30
|
+
return count;
|
|
31
|
+
}
|
|
32
|
+
// An item's marker and its box, from where the item starts: `- [ ]`,
|
|
33
|
+
// `1. [x]`, `* [X]`.
|
|
34
|
+
const TASK_MARKER = /^([*+-]|\d{1,9}[.)])([ \t]+)\[([ xX])\]/;
|
|
35
|
+
/**
|
|
36
|
+
* The text with the task item that starts at `offset` (its list marker's
|
|
37
|
+
* place in the text, as the parser gives it) ticked when it wasn't and
|
|
38
|
+
* unticked when it was. Everything else is left exactly as written. The
|
|
39
|
+
* text unchanged when no task item starts there.
|
|
40
|
+
*/
|
|
41
|
+
export function toggleTaskAt(markdown, offset) {
|
|
42
|
+
const match = TASK_MARKER.exec(markdown.slice(offset));
|
|
43
|
+
if (!match)
|
|
44
|
+
return markdown;
|
|
45
|
+
const box = offset + match[1].length + match[2].length + 1;
|
|
46
|
+
const next = match[3] === ' ' ? 'x' : ' ';
|
|
47
|
+
return markdown.slice(0, box) + next + markdown.slice(box + 1);
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.61.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/styles/index.css
CHANGED
|
@@ -158,3 +158,84 @@
|
|
|
158
158
|
@apply font-sans;
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/* Printed notes (EntityView's Print notes, src/components/app/entity-view-print.tsx).
|
|
163
|
+
While its copy is in <body>, paper shows that copy alone: the app beside
|
|
164
|
+
it (shell, header, rail, dialogs) is left off, and the copy is in the
|
|
165
|
+
light theme's colours even when the screen is dark. Its buttons (edit
|
|
166
|
+
pencils, move and remove) are left off too: paper has nothing to press.
|
|
167
|
+
Tables are ruled all round, never cut off at the scroll box's edge, and
|
|
168
|
+
their cells tall enough to write in; a heading stays with what follows
|
|
169
|
+
it; a section marked answerSpace leaves room under each numbered item. */
|
|
170
|
+
@media print {
|
|
171
|
+
body:has(> [data-print-notes]) > :not([data-print-notes]) {
|
|
172
|
+
display: none !important;
|
|
173
|
+
}
|
|
174
|
+
:root:has(> body > [data-print-notes]) {
|
|
175
|
+
--background: var(--white);
|
|
176
|
+
--foreground: var(--black);
|
|
177
|
+
--card: var(--white);
|
|
178
|
+
--card-foreground: var(--black);
|
|
179
|
+
--popover: var(--white);
|
|
180
|
+
--popover-foreground: var(--black);
|
|
181
|
+
--primary: var(--gray-950);
|
|
182
|
+
--primary-foreground: var(--gray-50);
|
|
183
|
+
--secondary: var(--gray-100);
|
|
184
|
+
--secondary-foreground: var(--gray-950);
|
|
185
|
+
--muted: var(--gray-100);
|
|
186
|
+
--muted-foreground: var(--gray-500);
|
|
187
|
+
--accent: var(--gray-100);
|
|
188
|
+
--accent-foreground: var(--gray-950);
|
|
189
|
+
--destructive-text: var(--red-700);
|
|
190
|
+
--success-text: var(--green-800);
|
|
191
|
+
--warning-text: var(--amber-800);
|
|
192
|
+
--info-text: var(--blue-800);
|
|
193
|
+
--link: var(--blue-700);
|
|
194
|
+
--highlight-amber: oklch(0.9 0.1 75);
|
|
195
|
+
--highlight-yellow: oklch(0.94 0.13 100);
|
|
196
|
+
--highlight-green: oklch(0.9 0.1 150);
|
|
197
|
+
--highlight-red: oklch(0.89 0.07 25);
|
|
198
|
+
--rule: var(--gray-400);
|
|
199
|
+
--heading-band: var(--gray-200);
|
|
200
|
+
--heading-bar: var(--blue-700);
|
|
201
|
+
--table-stripe: var(--white);
|
|
202
|
+
--border: var(--gray-400);
|
|
203
|
+
--input: var(--gray-200);
|
|
204
|
+
color-scheme: light;
|
|
205
|
+
background: var(--white);
|
|
206
|
+
}
|
|
207
|
+
[data-print-notes] button {
|
|
208
|
+
display: none !important;
|
|
209
|
+
}
|
|
210
|
+
[data-print-notes] :is(h1, h2, h3, h4, h5, h6) {
|
|
211
|
+
break-after: avoid;
|
|
212
|
+
}
|
|
213
|
+
[data-print-notes] [data-slot="table-container"] {
|
|
214
|
+
/* Not w-full: the text's inset margins would push its right edge
|
|
215
|
+
off the paper. */
|
|
216
|
+
width: auto;
|
|
217
|
+
overflow: visible;
|
|
218
|
+
border: 0;
|
|
219
|
+
border-radius: 0;
|
|
220
|
+
}
|
|
221
|
+
[data-print-notes] table {
|
|
222
|
+
border-collapse: collapse;
|
|
223
|
+
}
|
|
224
|
+
[data-print-notes] tr {
|
|
225
|
+
break-inside: avoid;
|
|
226
|
+
}
|
|
227
|
+
[data-print-notes] :is(th, td) {
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
background: var(--white);
|
|
230
|
+
}
|
|
231
|
+
[data-print-notes] td {
|
|
232
|
+
height: 2.5rem;
|
|
233
|
+
}
|
|
234
|
+
[data-print-notes] [data-answer-space] ol > li {
|
|
235
|
+
padding-bottom: 5rem;
|
|
236
|
+
break-inside: avoid;
|
|
237
|
+
}
|
|
238
|
+
[data-print-notes] [data-answer-space] li ol > li {
|
|
239
|
+
padding-bottom: 0;
|
|
240
|
+
}
|
|
241
|
+
}
|