@tristan2828/ui-foundation 4.52.0 → 4.54.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/conventions/docs/design-language.md +18 -1
- package/dist/components/app/fill-in-field.d.ts +65 -0
- package/dist/components/app/fill-in-field.js +99 -0
- package/dist/components/app/fill-in-queue.d.ts +13 -1
- package/dist/components/app/fill-in-queue.js +15 -3
- package/dist/components/app/markdown.d.ts +10 -1
- package/dist/components/app/markdown.js +37 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +82 -10
- package/dist/components/app/notes-outline.d.ts +17 -0
- package/dist/components/app/notes-outline.js +60 -0
- package/dist/components/app/prose-look.d.ts +8 -0
- package/dist/components/app/prose-look.js +8 -0
- package/dist/components/app/rich-text-editor.d.ts +11 -0
- package/dist/components/app/rich-text-editor.js +3 -0
- package/dist/components/app/rich-text-fold.d.ts +28 -0
- package/dist/components/app/rich-text-fold.js +130 -0
- package/dist/hooks/fold-store.d.ts +19 -0
- package/dist/hooks/fold-store.js +93 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/lib/markdown-fold.d.ts +59 -0
- package/dist/lib/markdown-fold.js +163 -0
- package/package.json +1 -1
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- [A list as cards](#a-list-as-cards) · lines 749–795
|
|
22
22
|
- [Key numbers on a record](#key-numbers-on-a-record) · lines 797–819
|
|
23
23
|
- [Picking at random](#picking-at-random) · lines 821–852
|
|
24
|
-
- [Filling in what's missing](#filling-in-whats-missing) · lines 854–
|
|
24
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 854–921
|
|
25
25
|
|
|
26
26
|
<!-- /index -->
|
|
27
27
|
|
|
@@ -902,3 +902,20 @@ size"); the list stays where a record is edited one field at a time.
|
|
|
902
902
|
- A refused save keeps the record and says why under the buttons, not
|
|
903
903
|
in a toast. It owns its states: skeletons, `ErrorState`, and the
|
|
904
904
|
empty queue, which is the good news ("Every project has a size").
|
|
905
|
+
- **Other kinds of value** (4.53): `FillInInput` is the row for text,
|
|
906
|
+
long text, a number, money (the currency's symbol before it) or a date
|
|
907
|
+
(`DateField`), and `FillInSelect` for a list too long for buttons (a
|
|
908
|
+
reference to another record). Each has the label above and a
|
|
909
|
+
suggestion under it as a dashed "Use $249.00" button, never filled in
|
|
910
|
+
for the person.
|
|
911
|
+
- **Fields as data:** `FillInField` takes a field as an API describes it
|
|
912
|
+
(`{ name, label, kind, choices, suggestion }`, `kind` one of choice,
|
|
913
|
+
reference, text, longText, number, money, date) and draws the row its
|
|
914
|
+
kind calls for: `FillInChoice` for up to nine choices, `FillInSelect`
|
|
915
|
+
for more, `FillInInput` for the rest. Its value is a choice's value, a
|
|
916
|
+
number for number and money, text otherwise, null for none. For a page
|
|
917
|
+
that draws records it doesn't know in advance.
|
|
918
|
+
- **Records from several places:** `groups` (`{ id, label, icon,
|
|
919
|
+
progress }`) and `getGroup` name a record's source above its heading,
|
|
920
|
+
with that group's own count; with more than one group counted, each
|
|
921
|
+
one's count is listed under the bar.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type FillInInputKind = 'text' | 'longText' | 'number' | 'money' | 'date';
|
|
3
|
+
export interface FillInSuggestion<V> {
|
|
4
|
+
value: V;
|
|
5
|
+
/** Why it's suggested: "from similar purchases". */
|
|
6
|
+
note?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export interface FillInInputProps {
|
|
9
|
+
label: string;
|
|
10
|
+
kind: FillInInputKind;
|
|
11
|
+
/** The control's text: `'YYYY-MM-DD'` for a date, a number as typed; `''` for none. */
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (value: string) => void;
|
|
14
|
+
/** Offered as a dashed "Use …" button under the control. */
|
|
15
|
+
suggestion?: FillInSuggestion<string>;
|
|
16
|
+
/** The currency of a money value, for its symbol and the suggestion. Default "USD". */
|
|
17
|
+
currency?: string;
|
|
18
|
+
locale?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/** One text, number, money or date field of a FillInQueue record. */
|
|
23
|
+
export declare function FillInInput({ label, kind, value, onChange, suggestion, currency, locale, placeholder, className, }: FillInInputProps): import("react").JSX.Element;
|
|
24
|
+
export interface FillInSelectProps<V extends string | number> {
|
|
25
|
+
label: string;
|
|
26
|
+
choices: readonly {
|
|
27
|
+
value: V;
|
|
28
|
+
label: ReactNode;
|
|
29
|
+
}[];
|
|
30
|
+
value: V | null | undefined;
|
|
31
|
+
onChange: (value: V | null) => void;
|
|
32
|
+
suggestion?: FillInSuggestion<V>;
|
|
33
|
+
/** What the trigger says with nothing picked. Default "Pick one". */
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
/** One field of a FillInQueue record picked from a long list. */
|
|
38
|
+
export declare function FillInSelect<V extends string | number>({ label, choices, value, onChange, suggestion, placeholder, className, }: FillInSelectProps<V>): import("react").JSX.Element;
|
|
39
|
+
export type FillInFieldKind = 'choice' | 'reference' | FillInInputKind;
|
|
40
|
+
export type FillInValue = string | number | null;
|
|
41
|
+
/** A field described as data, as an API sends it. */
|
|
42
|
+
export interface FillInFieldSpec {
|
|
43
|
+
name: string;
|
|
44
|
+
label: string;
|
|
45
|
+
kind: FillInFieldKind;
|
|
46
|
+
/** For `choice` and `reference`. */
|
|
47
|
+
choices?: readonly {
|
|
48
|
+
value: string | number;
|
|
49
|
+
label: string;
|
|
50
|
+
}[];
|
|
51
|
+
suggestion?: FillInSuggestion<string | number>;
|
|
52
|
+
}
|
|
53
|
+
export interface FillInFieldProps {
|
|
54
|
+
field: FillInFieldSpec;
|
|
55
|
+
/** The draft's value: a choice's value, a number for number and money, text for the rest; null for none. */
|
|
56
|
+
value: FillInValue | undefined;
|
|
57
|
+
onChange: (value: FillInValue) => void;
|
|
58
|
+
/** The number keys pick in this field when it's drawn as a row of choices. One per record. */
|
|
59
|
+
shortcut?: boolean;
|
|
60
|
+
currency?: string;
|
|
61
|
+
locale?: string;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
/** The row a field's kind calls for: FillInChoice, FillInSelect or FillInInput. */
|
|
65
|
+
export declare function FillInField({ field, value, onChange, shortcut, currency, locale, className }: FillInFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// FillInQueue's rows for values that aren't a short choice: text, a number,
|
|
3
|
+
// money, a date, and a long list to pick from. Each looks like FillInChoice
|
|
4
|
+
// (a label, the control, a suggestion under it) so a record mixes them.
|
|
5
|
+
//
|
|
6
|
+
// - FillInInput: one text, number, money or date value. The value is the
|
|
7
|
+
// control's own text (`'YYYY-MM-DD'` for a date, `''` for none); the app
|
|
8
|
+
// reads it. A suggestion is a dashed button under it ("Use $12.00"),
|
|
9
|
+
// never filled in for the person.
|
|
10
|
+
// - FillInSelect: one value from a list too long for a row of buttons
|
|
11
|
+
// (a reference to another record, a long enum), as a Select.
|
|
12
|
+
// - FillInField: given a field as data (`{ name, label, kind, choices }`,
|
|
13
|
+
// as an API describes it), the right row: FillInChoice for up to nine
|
|
14
|
+
// choices, FillInSelect for more, FillInInput for the rest. For a page
|
|
15
|
+
// that draws records it doesn't know in advance.
|
|
16
|
+
import { useId, useState } from 'react';
|
|
17
|
+
import { cn } from 'cn';
|
|
18
|
+
import { DateField } from './date-field.js';
|
|
19
|
+
import { FillInChoice } from './fill-in-queue.js';
|
|
20
|
+
import { Button } from '../ui/button.js';
|
|
21
|
+
import { Input } from '../ui/input.js';
|
|
22
|
+
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group.js';
|
|
23
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
|
|
24
|
+
import { Textarea } from '../ui/textarea.js';
|
|
25
|
+
import { formatPlainDate } from '../../lib/plain-date.js';
|
|
26
|
+
const currencySymbol = (currency, locale) => new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0).find((part) => part.type === 'currency')
|
|
27
|
+
?.value ?? currency;
|
|
28
|
+
function shown(kind, value, currency, locale) {
|
|
29
|
+
if (kind === 'date')
|
|
30
|
+
return formatPlainDate(value, { dateStyle: 'medium' }, locale);
|
|
31
|
+
const number = Number(value);
|
|
32
|
+
if (value.trim() === '' || !Number.isFinite(number))
|
|
33
|
+
return value;
|
|
34
|
+
if (kind === 'money')
|
|
35
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(number);
|
|
36
|
+
if (kind === 'number')
|
|
37
|
+
return new Intl.NumberFormat(locale).format(number);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/** One text, number, money or date field of a FillInQueue record. */
|
|
41
|
+
export function FillInInput({ label, kind, value, onChange, suggestion, currency = 'USD', locale, placeholder, className, }) {
|
|
42
|
+
const id = useId();
|
|
43
|
+
const control = (() => {
|
|
44
|
+
switch (kind) {
|
|
45
|
+
case 'date':
|
|
46
|
+
return _jsx(DateField, { id: id, value: value, onChange: onChange, clearable: true, locale: locale });
|
|
47
|
+
case 'longText':
|
|
48
|
+
return _jsx(Textarea, { id: id, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) });
|
|
49
|
+
case 'money':
|
|
50
|
+
return (_jsxs(InputGroup, { children: [_jsx(InputGroupAddon, { children: _jsx(InputGroupText, { children: currencySymbol(currency, locale) }) }), _jsx(InputGroupInput, { id: id, inputMode: "decimal", value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) })] }));
|
|
51
|
+
case 'number':
|
|
52
|
+
return (_jsx(Input, { id: id, type: "number", inputMode: "decimal", value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) }));
|
|
53
|
+
default:
|
|
54
|
+
return _jsx(Input, { id: id, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value) });
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return (_jsxs("div", { "data-slot": "fill-in-input", className: cn('flex flex-col gap-2', className), children: [_jsx("label", { htmlFor: id, className: "type-label", children: label }), control, suggestion !== undefined && suggestion.value !== '' && (_jsx(SuggestionButton, { text: shown(kind, suggestion.value, currency, locale), note: suggestion.note, used: value === suggestion.value, onUse: () => onChange(suggestion.value) }))] }));
|
|
58
|
+
}
|
|
59
|
+
/** One field of a FillInQueue record picked from a long list. */
|
|
60
|
+
export function FillInSelect({ label, choices, value, onChange, suggestion, placeholder = 'Pick one', className, }) {
|
|
61
|
+
const id = useId();
|
|
62
|
+
const keyOf = (v) => String(v);
|
|
63
|
+
const byKey = new Map(choices.map((choice) => [keyOf(choice.value), choice]));
|
|
64
|
+
const suggested = suggestion ? byKey.get(keyOf(suggestion.value)) : undefined;
|
|
65
|
+
return (_jsxs("div", { "data-slot": "fill-in-select", className: cn('flex flex-col gap-2', className), children: [_jsx("label", { htmlFor: id, className: "type-label", children: label }), _jsxs(Select, { value: value === null || value === undefined ? null : keyOf(value), onValueChange: (next) => onChange(next === null ? null : (byKey.get(String(next))?.value ?? null)), children: [_jsx(SelectTrigger, { id: id, className: "w-full", children: _jsx(SelectValue, { placeholder: placeholder, children: (key) => (key === null ? placeholder : (byKey.get(key)?.label ?? key)) }) }), _jsx(SelectContent, { children: choices.map((choice) => (_jsx(SelectItem, { value: keyOf(choice.value), children: choice.label }, keyOf(choice.value)))) })] }), suggested !== undefined && (_jsx(SuggestionButton, { text: suggested.label, note: suggestion?.note, used: value !== null && value !== undefined && keyOf(value) === keyOf(suggested.value), onUse: () => onChange(suggested.value) }))] }));
|
|
66
|
+
}
|
|
67
|
+
function SuggestionButton({ text, note, used, onUse }) {
|
|
68
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", size: "sm", variant: "outline", disabled: used, onClick: onUse, "data-slot": "fill-in-suggestion", className: "border-dashed border-info-text", children: ["Use ", text] }), note !== undefined && note !== null && _jsxs("span", { className: "type-caption text-muted-foreground", children: ["(", note, ")"] })] }));
|
|
69
|
+
}
|
|
70
|
+
const isNumeric = (kind) => kind === 'number' || kind === 'money';
|
|
71
|
+
const toText = (value) => (value === null || value === undefined ? '' : String(value));
|
|
72
|
+
/** The row a field's kind calls for: FillInChoice, FillInSelect or FillInInput. */
|
|
73
|
+
export function FillInField({ field, value, onChange, shortcut, currency, locale, className }) {
|
|
74
|
+
const { kind, label, choices = [], suggestion } = field;
|
|
75
|
+
// A number's text as typed ("1." on the way to "1.5"), kept while it
|
|
76
|
+
// still reads as the draft's value; replaced when the draft changes.
|
|
77
|
+
const [text, setText] = useState(() => toText(value));
|
|
78
|
+
const keeps = value === null || value === undefined
|
|
79
|
+
? text.trim() === '' || !Number.isFinite(Number(text))
|
|
80
|
+
: text.trim() !== '' && Number(text) === value;
|
|
81
|
+
if (isNumeric(kind) && !keeps)
|
|
82
|
+
setText(toText(value));
|
|
83
|
+
if (kind === 'choice' || kind === 'reference') {
|
|
84
|
+
const pick = (next) => onChange(next);
|
|
85
|
+
const current = value === undefined ? null : value;
|
|
86
|
+
if (choices.length <= 9) {
|
|
87
|
+
return (_jsx(FillInChoice, { label: label, choices: choices, value: current, onChange: pick, suggestion: suggestion, shortcut: shortcut, className: className }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsx(FillInSelect, { label: label, choices: choices, value: current, onChange: pick, suggestion: suggestion, className: className }));
|
|
90
|
+
}
|
|
91
|
+
const numeric = isNumeric(kind);
|
|
92
|
+
return (_jsx(FillInInput, { label: label, kind: kind, value: numeric ? text : toText(value), onChange: (next) => {
|
|
93
|
+
if (!numeric)
|
|
94
|
+
return onChange(next === '' ? null : next);
|
|
95
|
+
setText(next);
|
|
96
|
+
const number = Number(next);
|
|
97
|
+
onChange(next.trim() === '' || !Number.isFinite(number) ? null : number);
|
|
98
|
+
}, suggestion: suggestion === undefined ? undefined : { value: String(suggestion.value), note: suggestion.note }, currency: currency, locale: locale, className: className }));
|
|
99
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { AppError } from '../../api/contracts.js';
|
|
3
|
+
import type { IconComponent } from './icon-component.js';
|
|
3
4
|
export interface FillInQueueProgress {
|
|
4
5
|
/** Records filled so far. */
|
|
5
6
|
done: number;
|
|
@@ -8,6 +9,13 @@ export interface FillInQueueProgress {
|
|
|
8
9
|
/** The word after the count: "12 of 160 <label>". Default "filled". */
|
|
9
10
|
label?: string;
|
|
10
11
|
}
|
|
12
|
+
export interface FillInQueueGroup {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: IconComponent;
|
|
16
|
+
/** This group's own count, said beside its name on its records. */
|
|
17
|
+
progress?: Omit<FillInQueueProgress, 'label'>;
|
|
18
|
+
}
|
|
11
19
|
export interface FillInQueueProps<T> {
|
|
12
20
|
/** The records still missing something, in order. */
|
|
13
21
|
items: readonly T[];
|
|
@@ -22,6 +30,10 @@ export interface FillInQueueProps<T> {
|
|
|
22
30
|
onSkip?: (item: T) => void;
|
|
23
31
|
/** A bar and "12 of 160 filled" at the top. The app counts. */
|
|
24
32
|
progress?: FillInQueueProgress;
|
|
33
|
+
/** Where records come from: named above each record's heading. */
|
|
34
|
+
groups?: readonly FillInQueueGroup[];
|
|
35
|
+
/** The id of the record's group in `groups`. */
|
|
36
|
+
getGroup?: (item: T) => string;
|
|
25
37
|
/** Default "Save and next". */
|
|
26
38
|
saveLabel?: string;
|
|
27
39
|
/** Default "Skip". */
|
|
@@ -38,7 +50,7 @@ export interface FillInQueueProps<T> {
|
|
|
38
50
|
onIndexChange?: (index: number, item: T) => void;
|
|
39
51
|
className?: string;
|
|
40
52
|
}
|
|
41
|
-
export declare function FillInQueue<T>({ items, getKey, renderItem, onSave, canSave, onSkip, progress, saveLabel, skipLabel, loading, error, onRetry, empty, index: indexProp, onIndexChange, className, }: FillInQueueProps<T>): import("react").JSX.Element;
|
|
53
|
+
export declare function FillInQueue<T>({ items, getKey, renderItem, onSave, canSave, onSkip, progress, groups, getGroup, saveLabel, skipLabel, loading, error, onRetry, empty, index: indexProp, onIndexChange, className, }: FillInQueueProps<T>): import("react").JSX.Element;
|
|
42
54
|
export interface FillInChoiceProps<V extends string | number> {
|
|
43
55
|
/** Names the row of choices. */
|
|
44
56
|
label: string;
|
|
@@ -21,6 +21,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
21
21
|
// - Saving: the button shows it's pending; a refusal keeps the record and
|
|
22
22
|
// says why under the buttons, not in a toast.
|
|
23
23
|
// - Progress: the app counts (`progress`): a bar and "12 of 160 filled".
|
|
24
|
+
// - Groups: records from several sources (a module, a list) name theirs
|
|
25
|
+
// above the heading with `groups` and `getGroup`: its icon and label,
|
|
26
|
+
// and, when the group has its own `progress`, "3 of 12 filled" beside
|
|
27
|
+
// it. With more than one group counted, each one's count is listed
|
|
28
|
+
// under the bar.
|
|
24
29
|
// - States: `loading` (skeletons), `error` (ErrorState, with `onRetry`),
|
|
25
30
|
// and an empty queue (`empty`, default "Nothing left to fill in").
|
|
26
31
|
//
|
|
@@ -68,7 +73,7 @@ function ownsEnter(element) {
|
|
|
68
73
|
return true;
|
|
69
74
|
return (element.closest('button, a[href], summary, [role=button], [role=link], [role=combobox], [role=listbox], [role=option], [role=menu], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=switch], [role=checkbox], [role=radio], [role=tab]') !== null);
|
|
70
75
|
}
|
|
71
|
-
export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true, onSkip, progress, saveLabel = 'Save and next', skipLabel = 'Skip', loading = false, error, onRetry, empty = 'Nothing left to fill in', index: indexProp, onIndexChange, className, }) {
|
|
76
|
+
export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true, onSkip, progress, groups, getGroup, saveLabel = 'Save and next', skipLabel = 'Skip', loading = false, error, onRetry, empty = 'Nothing left to fill in', index: indexProp, onIndexChange, className, }) {
|
|
72
77
|
const keys = items.map(getKey);
|
|
73
78
|
const [held, setHeld] = useState(() => ({
|
|
74
79
|
key: indexProp !== undefined ? (keys[indexProp] ?? null) : null,
|
|
@@ -181,7 +186,8 @@ export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true,
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
};
|
|
184
|
-
const
|
|
189
|
+
const counted = groups?.filter((group) => group.progress !== undefined) ?? [];
|
|
190
|
+
const progressBar = (progress || counted.length > 1) && (_jsxs("div", { className: "flex flex-col gap-1.5", children: [progress && _jsx(ProgressLine, { progress: progress }), counted.length > 1 && (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 type-caption text-muted-foreground tabular-nums", "data-slot": "fill-in-queue-groups", children: counted.map((group) => (_jsxs("li", { children: [group.label, " ", group.progress?.done, " of ", group.progress?.total] }, group.id))) }))] }));
|
|
185
191
|
if (error !== undefined && error !== null && error !== false) {
|
|
186
192
|
return (_jsx("div", { "data-slot": "fill-in-queue", "data-state": "error", className: className, children: isAppError(error) ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : (_jsxs(Empty, { "data-state": "error", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: "Something went wrong" }), _jsx(EmptyDescription, { children: error })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, children: "Try again" }))] })) }));
|
|
187
193
|
}
|
|
@@ -192,7 +198,13 @@ export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true,
|
|
|
192
198
|
return (_jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "empty", className: cn('flex flex-col gap-6 outline-none', className), children: [progressBar, _jsx(Empty, { children: _jsx(EmptyHeader, { children: _jsx(EmptyTitle, { children: empty }) }) })] }));
|
|
193
199
|
}
|
|
194
200
|
const position = `Record ${index + 1} of ${items.length}`;
|
|
195
|
-
|
|
201
|
+
const groupId = getGroup?.(current);
|
|
202
|
+
const group = groupId === undefined ? undefined : groups?.find((g) => g.id === groupId);
|
|
203
|
+
return (_jsx(ShortcutContext.Provider, { value: register, children: _jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "success", onKeyDown: onKeyDown, className: cn('flex flex-col gap-6 outline-none', className), children: [_jsxs("div", { className: "flex flex-col gap-2", children: [progressBar, _jsx("p", { className: "type-caption text-muted-foreground tabular-nums", "data-slot": "fill-in-queue-position", children: position })] }), _jsxs("div", { className: "mx-auto flex w-full max-w-[40rem] flex-col gap-6", children: [_jsxs("div", { ref: recordRef, role: "group", "aria-label": position, tabIndex: -1, "data-slot": "fill-in-queue-record", className: "flex flex-col gap-6 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", children: [group && _jsx(GroupLine, { group: group, label: progress?.label }), renderItem(current)] }, currentKey ?? undefined), _jsxs("div", { className: "flex flex-col gap-2 border-t border-border pt-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(Button, { variant: "ghost", onClick: skip, disabled: saving, children: skipLabel }), _jsxs(Button, { onClick: () => void save(), disabled: !savable || saving, "aria-busy": saving || undefined, children: [saving && _jsx(Spinner, { "aria-hidden": "true", role: "presentation", "aria-label": undefined, "data-icon": "inline-start" }), saveLabel] })] }), saveError !== null && (_jsx("p", { role: "alert", className: "text-end type-caption text-destructive-text", children: saveError }))] })] })] }) }));
|
|
204
|
+
}
|
|
205
|
+
function GroupLine({ group, label = 'filled' }) {
|
|
206
|
+
const Icon = group.icon;
|
|
207
|
+
return (_jsxs("p", { className: "-mb-4 flex items-center gap-1.5 type-caption text-muted-foreground", "data-slot": "fill-in-queue-group", children: [Icon && _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), _jsx("span", { className: "font-medium", children: group.label }), group.progress && (_jsxs("span", { className: "tabular-nums", children: ["\u00B7 ", group.progress.done, " of ", group.progress.total, " ", label] }))] }));
|
|
196
208
|
}
|
|
197
209
|
function ProgressLine({ progress }) {
|
|
198
210
|
const { done, total, label = 'filled' } = progress;
|
|
@@ -17,6 +17,15 @@ export type MarkdownProps = {
|
|
|
17
17
|
* itself is never rewritten.
|
|
18
18
|
*/
|
|
19
19
|
headingFrom?: HeadingFrom;
|
|
20
|
+
/**
|
|
21
|
+
* Folds the text by its headings: each heading at its top level gets a
|
|
22
|
+
* button that folds what's under it (to the next heading of its level
|
|
23
|
+
* or higher). What's folded is remembered on this device under this key
|
|
24
|
+
* (name it for the app and the record: `"my-app:task:84:notes"`), and
|
|
25
|
+
* shared with a RichTextEditor or NotesOutline given the same key. The
|
|
26
|
+
* text is never changed.
|
|
27
|
+
*/
|
|
28
|
+
foldKey?: string;
|
|
20
29
|
className?: string;
|
|
21
30
|
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
22
|
-
export declare function Markdown({ children, headingLevel, headingFrom, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
31
|
+
export declare function Markdown({ children, headingLevel, headingFrom, foldKey, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -39,16 +39,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
39
39
|
//
|
|
40
40
|
// `==words==` is highlighted words (amber, or `=={green}words==`: yellow,
|
|
41
41
|
// green, red): a <mark> on the colour's wash (src/lib/markdown-highlight.ts).
|
|
42
|
-
|
|
42
|
+
//
|
|
43
|
+
// With `foldKey`, each heading at the text's top level has a button before
|
|
44
|
+
// it that folds its section away (src/lib/markdown-fold.ts), remembered on
|
|
45
|
+
// this device under the key and shared with an editor or NotesOutline
|
|
46
|
+
// given the same one. The text itself is never changed.
|
|
47
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
|
43
48
|
import ReactMarkdown, {} from 'react-markdown';
|
|
44
49
|
import remarkGfm from 'remark-gfm';
|
|
45
50
|
import { cn } from 'cn';
|
|
46
|
-
import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
|
|
51
|
+
import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
|
|
47
52
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
|
|
53
|
+
import { useFolds } from '../../hooks/fold-store.js';
|
|
48
54
|
import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
|
|
55
|
+
import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
|
|
49
56
|
import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
|
|
50
57
|
import { textTopHeading } from '../../lib/markdown-top-heading.js';
|
|
51
|
-
import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
|
|
58
|
+
import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
|
|
52
59
|
import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
|
|
53
60
|
const REMARK_PLUGINS = [remarkGfm, remarkCallouts, remarkHighlights];
|
|
54
61
|
const HIGHLIGHT_WASH = {
|
|
@@ -71,7 +78,7 @@ function omitNode(props) {
|
|
|
71
78
|
// sideways: a tab stop and a named region only while the table overflows
|
|
72
79
|
// it. Watched again after each render, so a header row with new words
|
|
73
80
|
// renames it.
|
|
74
|
-
function NotesTable(props) {
|
|
81
|
+
function NotesTable({ hidden, ...props }) {
|
|
75
82
|
const boxRef = useRef(null);
|
|
76
83
|
useEffect(() => {
|
|
77
84
|
const box = boxRef.current;
|
|
@@ -81,14 +88,30 @@ function NotesTable(props) {
|
|
|
81
88
|
const region = watchTableScroll(box, table, headerTexts(table));
|
|
82
89
|
return () => region.destroy();
|
|
83
90
|
});
|
|
84
|
-
return (_jsx(Table, { ...props, containerRef: boxRef,
|
|
91
|
+
return (_jsx(Table, { ...props, containerRef: boxRef,
|
|
92
|
+
// Folded away, the box goes with the table.
|
|
93
|
+
containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
|
|
85
94
|
}
|
|
95
|
+
// Folds a section by its heading's id: given only with `foldKey`.
|
|
96
|
+
const FoldContext = createContext(null);
|
|
86
97
|
// A heading by its Markdown level: the element the page's outline gives
|
|
87
98
|
// it (headingTag), the look its level has. The text's colour first, so a
|
|
88
|
-
// level's own (`###`'s muted label) wins.
|
|
99
|
+
// level's own (`###`'s muted label) wins. With `foldKey`, a top-level one
|
|
100
|
+
// (marked by rehypeFold) sits beside its fold button, the look on the two
|
|
101
|
+
// together; one with nothing under it keeps the button's room, so its
|
|
102
|
+
// words line up with the others'.
|
|
89
103
|
function heading(level, top, textTop) {
|
|
90
104
|
const Tag = headingTag(level, top, textTop);
|
|
91
|
-
return (props)
|
|
105
|
+
return function Heading(props) {
|
|
106
|
+
const toggle = useContext(FoldContext);
|
|
107
|
+
const { 'data-fold-id': foldId, 'data-foldable': foldable, 'data-folded': folded, 'data-fold-label': label, hidden, ...rest } = omitNode(props);
|
|
108
|
+
const look = cn('text-foreground', headingClass(level));
|
|
109
|
+
const band = headingBand(level) || undefined;
|
|
110
|
+
if (!toggle || foldId === undefined)
|
|
111
|
+
return _jsx(Tag, { ...rest, hidden: hidden, "data-band": band, className: look });
|
|
112
|
+
const open = folded !== 'true';
|
|
113
|
+
return (_jsxs("div", { "data-fold-id": foldId, hidden: hidden, "data-band": band, className: cn(FOLD_HEADING, look), children: [foldable === 'true' ? (_jsx("button", { type: "button", "aria-expanded": open, "aria-label": label || 'Section', "data-slot": "fold-button", className: FOLD_BUTTON, onClick: () => toggle(foldId), children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('size-4 transition-transform', !open && '-rotate-90') }) })) : (_jsx("span", { "aria-hidden": "true", className: "-ml-1 w-5 shrink-0" })), _jsx(Tag, { ...rest, className: "min-w-0 flex-1" })] }));
|
|
114
|
+
};
|
|
92
115
|
}
|
|
93
116
|
// Everything but the headings, which follow `headingLevel` (componentsFor).
|
|
94
117
|
const COMPONENTS = {
|
|
@@ -112,7 +135,7 @@ const COMPONENTS = {
|
|
|
112
135
|
// state in words, never a checkbox someone will try to tick.
|
|
113
136
|
input: ({ checked }) => 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" })),
|
|
114
137
|
blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
|
|
115
|
-
hr: () => _jsx("hr", { className: PROSE.rule }),
|
|
138
|
+
hr: ({ hidden }) => _jsx("hr", { hidden: hidden, className: PROSE.rule }),
|
|
116
139
|
// Highlighted words (remarkHighlights marks them): the colour's wash.
|
|
117
140
|
mark: (props) => {
|
|
118
141
|
const { children, ...rest } = omitNode(props);
|
|
@@ -128,7 +151,7 @@ const COMPONENTS = {
|
|
|
128
151
|
return _jsx("div", { ...rest, children: children });
|
|
129
152
|
const look = CALLOUT_LOOK[kind];
|
|
130
153
|
const Icon = look.icon;
|
|
131
|
-
return (_jsxs("div", { role: "note", "data-callout": kind, className: cn(CALLOUT_BOX, look.box), children: [_jsx(Icon, { "aria-hidden": "true", className: cn('mt-0.5 size-4 shrink-0', look.tint) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsxs("span", { className: "sr-only", children: [look.label, ": "] }), children] })] }));
|
|
154
|
+
return (_jsxs("div", { role: "note", "data-callout": kind, hidden: rest.hidden, className: cn(CALLOUT_BOX, look.box), children: [_jsx(Icon, { "aria-hidden": "true", className: cn('mt-0.5 size-4 shrink-0', look.tint) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsxs("span", { className: "sr-only", children: [look.label, ": "] }), children] })] }));
|
|
132
155
|
},
|
|
133
156
|
pre: (props) => (_jsx("pre", { ...omitNode(props), className: "overflow-x-auto rounded-lg bg-muted p-3 font-mono text-foreground [&_code]:bg-transparent [&_code]:p-0" })),
|
|
134
157
|
code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
|
|
@@ -161,8 +184,11 @@ function componentsFor(top, textTop) {
|
|
|
161
184
|
}
|
|
162
185
|
return components;
|
|
163
186
|
}
|
|
164
|
-
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', className, ...rest }) {
|
|
187
|
+
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, className, ...rest }) {
|
|
165
188
|
const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
|
|
166
189
|
const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
|
|
167
|
-
|
|
190
|
+
const [folded, setFolded] = useFolds(foldKey);
|
|
191
|
+
const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
|
|
192
|
+
const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
|
|
193
|
+
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(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: children }) }) }));
|
|
168
194
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, upload, imageAccept, imageMaxSize, headingLevel, headingFrom, foldKey, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -48,6 +48,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
48
48
|
// With `upload`, an image pasted, dropped or picked with the toolbar's
|
|
49
49
|
// Image button is uploaded through it and goes in as ``
|
|
50
50
|
// (rich-text-image-upload.ts); a refusal or a failed upload is a toast.
|
|
51
|
+
// - `foldKey`: the text folds by its headings, as <Markdown> does
|
|
52
|
+
// (rich-text-fold.ts), remembered on this device and shared with
|
|
53
|
+
// NotesOutline; the document never changes.
|
|
51
54
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
52
55
|
import { createPortal } from 'react-dom';
|
|
53
56
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
@@ -72,7 +75,10 @@ import { calloutHandler, remarkCalloutNodes } from '../../lib/markdown-callout.j
|
|
|
72
75
|
import { DEFAULT_HIGHLIGHT, highlightHandler, isHighlightColor, remarkHighlightNodes } from '../../lib/markdown-highlight.js';
|
|
73
76
|
import { textTopHeading } from '../../lib/markdown-top-heading.js';
|
|
74
77
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
75
|
-
import {
|
|
78
|
+
import { foldStore, listenForOtherTabs } from '../../hooks/fold-store.js';
|
|
79
|
+
import { toggleFold } from '../../lib/markdown-fold.js';
|
|
80
|
+
import { DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag } from './prose-look.js';
|
|
81
|
+
import { foldPlugin, foldSpecOf, setFolds } from './rich-text-fold.js';
|
|
76
82
|
import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
|
|
77
83
|
import { tableAt, toggleFormat } from './rich-text-formats.js';
|
|
78
84
|
import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
|
|
@@ -177,16 +183,69 @@ function htmlToText(fragment) {
|
|
|
177
183
|
}
|
|
178
184
|
// A heading: the element `headingLevel` gives its level, the look its
|
|
179
185
|
// level has. Marked as a band for PROSE_INSET (`#` and `##`).
|
|
180
|
-
|
|
186
|
+
//
|
|
187
|
+
// With `foldKey` (`onFold`), it sits beside its fold button, as in
|
|
188
|
+
// <Markdown>: the look on the two together, the button out of the tab
|
|
189
|
+
// order (Ctrl/Cmd+Alt+[ and ] and NotesOutline are the keyboard's way),
|
|
190
|
+
// kept invisible where there's nothing under the heading to fold, or the
|
|
191
|
+
// heading isn't at the top level.
|
|
192
|
+
function headingView(node, top, textTop, decorations, onFold) {
|
|
181
193
|
const level = Number(node.attrs.level);
|
|
182
|
-
const
|
|
183
|
-
|
|
194
|
+
const heading = document.createElement(headingTag(level, top, textTop));
|
|
195
|
+
const sameLevel = (next) => next.type === node.type && next.attrs.level === node.attrs.level;
|
|
196
|
+
if (!onFold) {
|
|
197
|
+
heading.className = headingClass(level);
|
|
198
|
+
if (headingBand(level))
|
|
199
|
+
heading.dataset.band = '';
|
|
200
|
+
return { dom: heading, contentDOM: heading, update: sameLevel };
|
|
201
|
+
}
|
|
202
|
+
const dom = document.createElement('div');
|
|
203
|
+
dom.className = cn(FOLD_HEADING, headingClass(level));
|
|
184
204
|
if (headingBand(level))
|
|
185
205
|
dom.dataset.band = '';
|
|
206
|
+
heading.className = 'min-w-0 flex-1';
|
|
207
|
+
const button = document.createElement('button');
|
|
208
|
+
button.type = 'button';
|
|
209
|
+
button.tabIndex = -1;
|
|
210
|
+
button.contentEditable = 'false';
|
|
211
|
+
button.dataset.slot = 'fold-button';
|
|
212
|
+
const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
213
|
+
chevron.setAttribute('viewBox', '0 0 24 24');
|
|
214
|
+
chevron.setAttribute('aria-hidden', 'true');
|
|
215
|
+
chevron.innerHTML =
|
|
216
|
+
'<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />';
|
|
217
|
+
button.append(chevron);
|
|
218
|
+
dom.append(button, heading);
|
|
219
|
+
let spec = null;
|
|
220
|
+
const render = (decorations) => {
|
|
221
|
+
spec = foldSpecOf(decorations);
|
|
222
|
+
button.className = cn(FOLD_BUTTON, !spec?.foldable && 'invisible');
|
|
223
|
+
chevron.setAttribute('class', cn('size-4 transition-transform', spec?.folded && '-rotate-90'));
|
|
224
|
+
button.setAttribute('aria-expanded', String(!spec?.folded));
|
|
225
|
+
button.setAttribute('aria-label', spec?.label || 'Section');
|
|
226
|
+
if (spec)
|
|
227
|
+
dom.dataset.foldId = spec.id;
|
|
228
|
+
else
|
|
229
|
+
delete dom.dataset.foldId;
|
|
230
|
+
};
|
|
231
|
+
render(decorations);
|
|
232
|
+
// A press keeps the caret where it is.
|
|
233
|
+
button.addEventListener('mousedown', (event) => event.preventDefault());
|
|
234
|
+
button.addEventListener('click', () => {
|
|
235
|
+
if (spec?.foldable)
|
|
236
|
+
onFold(spec.id);
|
|
237
|
+
});
|
|
186
238
|
return {
|
|
187
239
|
dom,
|
|
188
|
-
contentDOM:
|
|
189
|
-
update: (next) =>
|
|
240
|
+
contentDOM: heading,
|
|
241
|
+
update: (next, decorations) => {
|
|
242
|
+
if (!sameLevel(next))
|
|
243
|
+
return false;
|
|
244
|
+
render(decorations);
|
|
245
|
+
return true;
|
|
246
|
+
},
|
|
247
|
+
stopEvent: (event) => button.contains(event.target),
|
|
248
|
+
ignoreMutation: (mutation) => mutation.type !== 'selection' && !heading.contains(mutation.target),
|
|
190
249
|
};
|
|
191
250
|
}
|
|
192
251
|
// A task-list item: its checkbox toggles `checked`. Any other list item
|
|
@@ -296,7 +355,7 @@ function applyLink(view, target, href) {
|
|
|
296
355
|
}
|
|
297
356
|
view.dispatch(tr.scrollIntoView());
|
|
298
357
|
}
|
|
299
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
358
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, upload, imageAccept, imageMaxSize, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
300
359
|
const rootRef = useRef(null);
|
|
301
360
|
const editorRef = useRef(null);
|
|
302
361
|
const [linkBox, setLinkBox] = useState(null);
|
|
@@ -483,6 +542,13 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
483
542
|
});
|
|
484
543
|
};
|
|
485
544
|
openLinkRef.current = openLink;
|
|
545
|
+
// Folding by headings, with `foldKey` (read once, when it mounts):
|
|
546
|
+
// the fold store is the state, the plugin draws it.
|
|
547
|
+
const folds = foldKey === undefined
|
|
548
|
+
? []
|
|
549
|
+
: [$prose(() => foldPlugin({ initial: foldStore.get(foldKey), onChange: (folded) => foldStore.set(foldKey, folded) }))];
|
|
550
|
+
const onFold = foldKey === undefined ? undefined : (id) => foldStore.set(foldKey, toggleFold(foldStore.get(foldKey), id));
|
|
551
|
+
let stopFolds = () => { };
|
|
486
552
|
// Paste, copy, the editor's own keys and the placeholder.
|
|
487
553
|
const slashing = $prose(() => slashPlugin());
|
|
488
554
|
// Images pasted or dropped, uploaded through the app's `upload`; with
|
|
@@ -693,7 +759,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
693
759
|
},
|
|
694
760
|
nodeViews: {
|
|
695
761
|
// The level when it mounts, as with defaultValue.
|
|
696
|
-
heading: (node) => headingView(node, headingLevel, textTop),
|
|
762
|
+
heading: (node, _view, _getPos, decorations) => headingView(node, headingLevel, textTop, decorations, onFold),
|
|
697
763
|
callout: (node, _view, getPos) => calloutView(node, getPos, registry),
|
|
698
764
|
table: (node, view, getPos) => tableView(node, view, getPos, registry),
|
|
699
765
|
list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
|
|
@@ -712,7 +778,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
712
778
|
.use(slashing)
|
|
713
779
|
.use(imageUploads)
|
|
714
780
|
.use(imageInput)
|
|
715
|
-
.use(input)
|
|
781
|
+
.use(input)
|
|
782
|
+
.use(folds);
|
|
716
783
|
void make.create().then((editor) => {
|
|
717
784
|
if (cancelled) {
|
|
718
785
|
void editor.destroy();
|
|
@@ -725,9 +792,14 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
725
792
|
setEditorState(view.state);
|
|
726
793
|
if (autoFocus)
|
|
727
794
|
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
795
|
+
if (foldKey !== undefined) {
|
|
796
|
+
listenForOtherTabs();
|
|
797
|
+
stopFolds = foldStore.subscribe(foldKey, () => view.dispatch(setFolds(view.state, foldStore.get(foldKey))));
|
|
798
|
+
}
|
|
728
799
|
});
|
|
729
800
|
return () => {
|
|
730
801
|
cancelled = true;
|
|
802
|
+
stopFolds();
|
|
731
803
|
setEditorState(null);
|
|
732
804
|
setEditorView(null);
|
|
733
805
|
const editor = editorRef.current;
|
|
@@ -879,7 +951,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
879
951
|
// Cleared, so picking the same file again still counts.
|
|
880
952
|
event.target.value = '';
|
|
881
953
|
imagesPicked(files);
|
|
882
|
-
} })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
|
|
954
|
+
} })), _jsx("div", { ref: rootRef, "data-fold-scope": foldKey, className: cn(!inline && 'min-h-24 px-2.5 py-2',
|
|
883
955
|
// Full screen: the text scrolls under the toolbar, in a column to read.
|
|
884
956
|
fullScreen && 'min-h-0 flex-1 overflow-y-auto px-4 py-3 md:px-8 [&_.ProseMirror]:mx-auto [&_.ProseMirror]:w-full [&_.ProseMirror]:max-w-3xl',
|
|
885
957
|
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type NotesOutlineProps = {
|
|
2
|
+
/** The text whose headings are listed. */
|
|
3
|
+
markdown: string;
|
|
4
|
+
/** The `foldKey` of the <Markdown> or RichTextEditor showing the text. */
|
|
5
|
+
foldKey: string;
|
|
6
|
+
/**
|
|
7
|
+
* How many heading levels are listed, from the text's highest down: 2
|
|
8
|
+
* (the default) lists `#` and `##` in a text that starts at `#`.
|
|
9
|
+
*/
|
|
10
|
+
depth?: number;
|
|
11
|
+
/** With fewer listed headings than this, the outline shows nothing. 2 by default. */
|
|
12
|
+
minHeadings?: number;
|
|
13
|
+
/** Names the outline's landmark. "Outline" by default. */
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function NotesOutline({ markdown, foldKey, depth, minHeadings, 'aria-label': ariaLabel, className, }: NotesOutlineProps): import("react").JSX.Element | null;
|