@tristan2828/ui-foundation 4.53.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/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 +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/markdown-fold.d.ts +59 -0
- package/dist/lib/markdown-fold.js +163 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// A long text's headings as a list to jump with, beside <Markdown> or the
|
|
3
|
+
// rich-text editor given the same `foldKey`: each heading a button that
|
|
4
|
+
// opens its section (and any it sits under) and brings it into view, with
|
|
5
|
+
// Fold all and Open all above. The text's top-level headings, as folding
|
|
6
|
+
// sees them (src/lib/markdown-fold.ts); the deepest levels are left out
|
|
7
|
+
// (`depth`), so a long text's outline stays a list of its parts.
|
|
8
|
+
//
|
|
9
|
+
// The jump lands on the heading's fold button, so from the keyboard the
|
|
10
|
+
// next Enter folds what was just opened. The outline reads the Markdown
|
|
11
|
+
// it's given: beside an editor, the text as last saved.
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
import { ChevronRightIcon } from 'lucide-react';
|
|
14
|
+
import { cn } from 'cn';
|
|
15
|
+
import { Button } from '../ui/button.js';
|
|
16
|
+
import { useFolds } from '../../hooks/fold-store.js';
|
|
17
|
+
import { usePrefersReducedMotion } from '../../hooks/use-prefers-reduced-motion.js';
|
|
18
|
+
import { allFoldable, markdownFoldPlan, openTo } from '../../lib/markdown-fold.js';
|
|
19
|
+
// Each level's indent, from the highest listed.
|
|
20
|
+
const INDENT = ['pl-2', 'pl-6', 'pl-10', 'pl-14', 'pl-18', 'pl-22'];
|
|
21
|
+
// The element showing the heading `id` in the text under `foldKey`.
|
|
22
|
+
function headingElement(foldKey, id) {
|
|
23
|
+
for (const scope of document.querySelectorAll('[data-fold-scope]')) {
|
|
24
|
+
if (scope.dataset.foldScope !== foldKey)
|
|
25
|
+
continue;
|
|
26
|
+
for (const heading of scope.querySelectorAll('[data-fold-id]'))
|
|
27
|
+
if (heading.dataset.foldId === id)
|
|
28
|
+
return heading;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function NotesOutline({ markdown, foldKey, depth = 2, minHeadings = 2, 'aria-label': ariaLabel = 'Outline', className, }) {
|
|
33
|
+
const [folded, setFolded] = useFolds(foldKey);
|
|
34
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
35
|
+
const plan = useMemo(() => markdownFoldPlan(markdown), [markdown]);
|
|
36
|
+
const top = Math.min(...plan.headings.map((heading) => heading.level));
|
|
37
|
+
const listed = plan.headings.filter((heading) => heading.level < top + depth);
|
|
38
|
+
if (listed.length < minHeadings)
|
|
39
|
+
return null;
|
|
40
|
+
const foldable = allFoldable(plan);
|
|
41
|
+
const anyFolded = [...folded].some((id) => foldable.has(id));
|
|
42
|
+
const allFolded = [...foldable].every((id) => folded.has(id));
|
|
43
|
+
const jump = (id) => {
|
|
44
|
+
setFolded(openTo(plan, folded, id));
|
|
45
|
+
// Once the text has opened up to it.
|
|
46
|
+
requestAnimationFrame(() => {
|
|
47
|
+
const heading = headingElement(foldKey, id);
|
|
48
|
+
if (!heading)
|
|
49
|
+
return;
|
|
50
|
+
heading.scrollIntoView({ block: 'start', behavior: reducedMotion ? 'auto' : 'smooth' });
|
|
51
|
+
const button = heading.querySelector('[data-slot="fold-button"]');
|
|
52
|
+
if (button && getComputedStyle(button).visibility !== 'hidden')
|
|
53
|
+
button.focus({ preventScroll: true });
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs("nav", { "aria-label": ariaLabel, "data-slot": "notes-outline", className: cn('flex min-w-0 flex-col gap-2', className), children: [_jsxs("div", { className: "flex flex-wrap gap-1", children: [_jsx(Button, { size: "xs", variant: "outline", disabled: allFolded, onClick: () => setFolded(foldable), children: "Fold all" }), _jsx(Button, { size: "xs", variant: "outline", disabled: !anyFolded, onClick: () => setFolded(new Set()), children: "Open all" })] }), _jsx("ul", { className: "flex flex-col", children: listed.map((heading) => {
|
|
57
|
+
const isFolded = heading.foldable && folded.has(heading.id);
|
|
58
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => jump(heading.id), className: cn('flex w-full min-w-0 items-center gap-1 rounded-md py-1 pr-2 text-left type-body text-foreground outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50', INDENT[Math.min(heading.level - top, INDENT.length - 1)], heading.level > top && 'text-muted-foreground hover:text-foreground'), children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: heading.text || 'Untitled section' }), isFolded && (_jsxs(_Fragment, { children: [_jsx(ChevronRightIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (folded)" })] }))] }) }, heading.id));
|
|
59
|
+
}) })] }));
|
|
60
|
+
}
|
|
@@ -26,6 +26,14 @@ export declare const PROSE: {
|
|
|
26
26
|
/** `---`: a 2px line in the rule tone, with room around it. */
|
|
27
27
|
readonly rule: "my-2 h-0.5 rounded-full border-0 bg-rule";
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* A heading's fold button (`foldKey`), before its words, the line's height
|
|
31
|
+
* so it sits on the first line of a heading that wraps. Its chevron points
|
|
32
|
+
* down while the section shows and right while it's folded.
|
|
33
|
+
*/
|
|
34
|
+
export declare const FOLD_BUTTON = "-ml-1 flex h-[1lh] w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50";
|
|
35
|
+
/** A heading with a fold button: the button and the words side by side, the look on both. */
|
|
36
|
+
export declare const FOLD_HEADING = "flex items-start gap-1";
|
|
29
37
|
/**
|
|
30
38
|
* On the text's container: when it has a band (a `#` or `##`, marked
|
|
31
39
|
* `data-band`, whatever element its `headingLevel` makes it), everything
|
|
@@ -32,6 +32,14 @@ export const PROSE = {
|
|
|
32
32
|
/** `---`: a 2px line in the rule tone, with room around it. */
|
|
33
33
|
rule: 'my-2 h-0.5 rounded-full border-0 bg-rule',
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* A heading's fold button (`foldKey`), before its words, the line's height
|
|
37
|
+
* so it sits on the first line of a heading that wraps. Its chevron points
|
|
38
|
+
* down while the section shows and right while it's folded.
|
|
39
|
+
*/
|
|
40
|
+
export const FOLD_BUTTON = '-ml-1 flex h-[1lh] w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50';
|
|
41
|
+
/** A heading with a fold button: the button and the words side by side, the look on both. */
|
|
42
|
+
export const FOLD_HEADING = 'flex items-start gap-1';
|
|
35
43
|
/**
|
|
36
44
|
* On the text's container: when it has a band (a `#` or `##`, marked
|
|
37
45
|
* `data-band`, whatever element its `headingLevel` makes it), everything
|
|
@@ -69,6 +69,17 @@ export type RichTextEditorProps = {
|
|
|
69
69
|
* `headingLevel` too. The Markdown saved is never rewritten.
|
|
70
70
|
*/
|
|
71
71
|
headingFrom?: HeadingFrom;
|
|
72
|
+
/**
|
|
73
|
+
* Folds the text by its headings, as `<Markdown>`'s `foldKey` does: each
|
|
74
|
+
* heading at the text's top level has a button that folds what's under
|
|
75
|
+
* it, Ctrl/Cmd+Alt+[ folds the section the caret is in and
|
|
76
|
+
* Ctrl/Cmd+Alt+] opens the heading it's on, and the caret moving into a
|
|
77
|
+
* folded section opens it. Remembered on this device under this key,
|
|
78
|
+
* and shared with `<Markdown>` and NotesOutline given the same one.
|
|
79
|
+
* Only the view folds: the Markdown saved never changes. Read once,
|
|
80
|
+
* when it mounts.
|
|
81
|
+
*/
|
|
82
|
+
foldKey?: string;
|
|
72
83
|
className?: string;
|
|
73
84
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
74
85
|
'aria-label'?: string;
|
|
@@ -23,6 +23,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
23
23
|
// Image button is uploaded through the app's own storage and goes in as
|
|
24
24
|
// ``; while it uploads, a placeholder shows where it will go.
|
|
25
25
|
//
|
|
26
|
+
// With `foldKey`, the text folds by its headings (NotesOutline lists them
|
|
27
|
+
// beside it), the Markdown untouched.
|
|
28
|
+
//
|
|
26
29
|
// The editor (Milkdown) is loaded on first use, so a page that only reads
|
|
27
30
|
// Markdown never downloads it. Until it arrives, a placeholder the size of
|
|
28
31
|
// the field shows.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { Plugin, PluginKey, type EditorState } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
4
|
+
import { type FoldPlan } from '../../lib/markdown-fold.js';
|
|
5
|
+
/** What a heading's node view hears of its fold, through its node decoration's spec. */
|
|
6
|
+
export type FoldSpec = {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
foldable: boolean;
|
|
10
|
+
folded: boolean;
|
|
11
|
+
};
|
|
12
|
+
type FoldState = {
|
|
13
|
+
folded: ReadonlySet<string>;
|
|
14
|
+
plan: FoldPlan;
|
|
15
|
+
decorations: DecorationSet;
|
|
16
|
+
};
|
|
17
|
+
export declare const foldPluginKey: PluginKey<FoldState>;
|
|
18
|
+
/** The fold plan of a document's top-level blocks. */
|
|
19
|
+
export declare function docFoldPlan(doc: ProseNode, folded?: ReadonlySet<string>): FoldPlan;
|
|
20
|
+
/** A heading node view's fold, from the decorations ProseMirror gives it. */
|
|
21
|
+
export declare function foldSpecOf(decorations: readonly Decoration[]): FoldSpec | null;
|
|
22
|
+
/** A transaction that sets what's folded, from outside the editor. */
|
|
23
|
+
export declare function setFolds(state: EditorState, folded: ReadonlySet<string>): import("prosemirror-state").Transaction;
|
|
24
|
+
export declare function foldPlugin(options: {
|
|
25
|
+
initial: ReadonlySet<string>;
|
|
26
|
+
onChange: (folded: ReadonlySet<string>) => void;
|
|
27
|
+
}): Plugin<FoldState>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Plugin, PluginKey, TextSelection } from '@milkdown/kit/prose/state';
|
|
2
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
3
|
+
import { foldPlan, openAt, toggleFold } from '../../lib/markdown-fold.js';
|
|
4
|
+
export const foldPluginKey = new PluginKey('fold');
|
|
5
|
+
/** The fold plan of a document's top-level blocks. */
|
|
6
|
+
export function docFoldPlan(doc, folded) {
|
|
7
|
+
const blocks = [];
|
|
8
|
+
doc.forEach((node) => blocks.push(node.type.name === 'heading' ? { level: Number(node.attrs.level), text: node.textContent } : null));
|
|
9
|
+
return foldPlan(blocks, folded);
|
|
10
|
+
}
|
|
11
|
+
function build(doc, folded) {
|
|
12
|
+
const plan = docFoldPlan(doc, folded);
|
|
13
|
+
const headings = new Map(plan.headings.map((heading) => [heading.index, heading]));
|
|
14
|
+
const decorations = [];
|
|
15
|
+
doc.forEach((node, offset, index) => {
|
|
16
|
+
const heading = headings.get(index);
|
|
17
|
+
const attrs = {};
|
|
18
|
+
if (plan.hidden.has(index))
|
|
19
|
+
attrs.hidden = '';
|
|
20
|
+
if (heading) {
|
|
21
|
+
const spec = {
|
|
22
|
+
id: heading.id,
|
|
23
|
+
label: heading.text,
|
|
24
|
+
foldable: heading.foldable,
|
|
25
|
+
folded: heading.foldable && folded.has(heading.id),
|
|
26
|
+
};
|
|
27
|
+
decorations.push(Decoration.node(offset, offset + node.nodeSize, attrs, { fold: spec }));
|
|
28
|
+
}
|
|
29
|
+
else if (attrs.hidden !== undefined) {
|
|
30
|
+
decorations.push(Decoration.node(offset, offset + node.nodeSize, attrs));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return { folded, plan, decorations: DecorationSet.create(doc, decorations) };
|
|
34
|
+
}
|
|
35
|
+
/** A heading node view's fold, from the decorations ProseMirror gives it. */
|
|
36
|
+
export function foldSpecOf(decorations) {
|
|
37
|
+
for (const decoration of decorations) {
|
|
38
|
+
const spec = decoration.spec.fold;
|
|
39
|
+
if (spec)
|
|
40
|
+
return spec;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** A transaction that sets what's folded, from outside the editor. */
|
|
45
|
+
export function setFolds(state, folded) {
|
|
46
|
+
return state.tr.setMeta(foldPluginKey, folded).setMeta('addToHistory', false);
|
|
47
|
+
}
|
|
48
|
+
// The innermost foldable heading whose section (or heading) holds the
|
|
49
|
+
// top-level block at `index`.
|
|
50
|
+
function headingAround(plan, index) {
|
|
51
|
+
let found;
|
|
52
|
+
for (const heading of plan.headings) {
|
|
53
|
+
if (heading.foldable && heading.index <= index && index < heading.end)
|
|
54
|
+
found = heading;
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
// The caret at the end of the top-level block at `index` (a heading).
|
|
59
|
+
function caretToEnd(state, index) {
|
|
60
|
+
let end = 0;
|
|
61
|
+
state.doc.forEach((node, offset, at) => {
|
|
62
|
+
if (at === index)
|
|
63
|
+
end = offset + node.nodeSize - 1;
|
|
64
|
+
});
|
|
65
|
+
return state.tr.setSelection(TextSelection.create(state.doc, end)).scrollIntoView();
|
|
66
|
+
}
|
|
67
|
+
export function foldPlugin(options) {
|
|
68
|
+
return new Plugin({
|
|
69
|
+
key: foldPluginKey,
|
|
70
|
+
state: {
|
|
71
|
+
init: (_config, state) => build(state.doc, options.initial),
|
|
72
|
+
apply: (tr, value, _old, state) => {
|
|
73
|
+
const next = tr.getMeta(foldPluginKey);
|
|
74
|
+
if (!next && !tr.docChanged)
|
|
75
|
+
return value;
|
|
76
|
+
return build(state.doc, next ?? value.folded);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
props: {
|
|
80
|
+
decorations: (state) => foldPluginKey.getState(state)?.decorations,
|
|
81
|
+
handleKeyDown: (view, event) => {
|
|
82
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
83
|
+
if (!mod || !event.altKey || event.shiftKey)
|
|
84
|
+
return false;
|
|
85
|
+
if (event.code !== 'BracketLeft' && event.code !== 'BracketRight')
|
|
86
|
+
return false;
|
|
87
|
+
const fold = foldPluginKey.getState(view.state);
|
|
88
|
+
if (!fold)
|
|
89
|
+
return false;
|
|
90
|
+
const index = view.state.selection.$from.index(0);
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
if (event.code === 'BracketLeft') {
|
|
93
|
+
const heading = headingAround(fold.plan, index);
|
|
94
|
+
if (!heading || fold.folded.has(heading.id))
|
|
95
|
+
return true;
|
|
96
|
+
// The caret to the end of the heading, which stays in sight.
|
|
97
|
+
view.dispatch(caretToEnd(view.state, heading.index));
|
|
98
|
+
options.onChange(toggleFold(fold.folded, heading.id));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const heading = fold.plan.headings.find((one) => one.index === index);
|
|
102
|
+
if (heading && fold.folded.has(heading.id))
|
|
103
|
+
options.onChange(toggleFold(fold.folded, heading.id));
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
view: () => ({
|
|
108
|
+
update: (view, previous) => {
|
|
109
|
+
const fold = foldPluginKey.getState(view.state);
|
|
110
|
+
if (!fold)
|
|
111
|
+
return;
|
|
112
|
+
const index = view.state.selection.$from.index(0);
|
|
113
|
+
if (!fold.plan.hidden.has(index))
|
|
114
|
+
return;
|
|
115
|
+
// Folded around the caret (a fold button, the outline): the caret
|
|
116
|
+
// goes to the end of the heading that folded, which shows.
|
|
117
|
+
if (fold.folded !== foldPluginKey.getState(previous)?.folded && view.state.selection.eq(previous.selection)) {
|
|
118
|
+
const outer = fold.plan.headings.find((heading) => fold.folded.has(heading.id) && heading.foldable && heading.index < index && index < heading.end);
|
|
119
|
+
if (outer)
|
|
120
|
+
queueMicrotask(() => view.dispatch(caretToEnd(view.state, outer.index)));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// The caret put in a folded section: open it (after this update,
|
|
124
|
+
// as the change comes back in a transaction of its own).
|
|
125
|
+
const opened = openAt(fold.plan, fold.folded, index);
|
|
126
|
+
queueMicrotask(() => options.onChange(opened));
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
2
|
+
export type FoldStore = {
|
|
3
|
+
/** The ids folded under `key`: the same set until it changes. */
|
|
4
|
+
get: (key: string) => ReadonlySet<string>;
|
|
5
|
+
set: (key: string, folded: ReadonlySet<string>) => void;
|
|
6
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
7
|
+
/** Another tab changed `key` (a `storage` event): re-read it. */
|
|
8
|
+
changedElsewhere: (key: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function createFoldStore(getStorage: () => StorageLike | undefined): FoldStore;
|
|
11
|
+
export declare const foldStore: FoldStore;
|
|
12
|
+
export declare function listenForOtherTabs(): void;
|
|
13
|
+
/**
|
|
14
|
+
* `[folded, setFolded]`: the heading ids folded under `foldKey`, shared
|
|
15
|
+
* with everything else given that key. Without a key it's plain state,
|
|
16
|
+
* forgotten with the page.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useFolds(foldKey: string | undefined): readonly [ReadonlySet<string>, (folded: ReadonlySet<string>) => void];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
const NONE = new Set();
|
|
3
|
+
function parse(stored) {
|
|
4
|
+
if (!stored)
|
|
5
|
+
return NONE;
|
|
6
|
+
try {
|
|
7
|
+
const ids = JSON.parse(stored);
|
|
8
|
+
return Array.isArray(ids) ? new Set(ids.filter((id) => typeof id === 'string')) : NONE;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return NONE;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function createFoldStore(getStorage) {
|
|
15
|
+
// Each key's set as last read or set: one object per state, so React
|
|
16
|
+
// sees a change only when there is one.
|
|
17
|
+
const current = new Map();
|
|
18
|
+
const listeners = new Map();
|
|
19
|
+
const notify = (key) => {
|
|
20
|
+
for (const listener of listeners.get(key) ?? [])
|
|
21
|
+
listener();
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
get(key) {
|
|
25
|
+
let folded = current.get(key);
|
|
26
|
+
if (!folded) {
|
|
27
|
+
try {
|
|
28
|
+
folded = parse(getStorage()?.getItem(key));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
folded = NONE;
|
|
32
|
+
}
|
|
33
|
+
current.set(key, folded);
|
|
34
|
+
}
|
|
35
|
+
return folded;
|
|
36
|
+
},
|
|
37
|
+
set(key, folded) {
|
|
38
|
+
const next = new Set(folded);
|
|
39
|
+
current.set(key, next);
|
|
40
|
+
try {
|
|
41
|
+
if (next.size === 0)
|
|
42
|
+
getStorage()?.removeItem(key);
|
|
43
|
+
else
|
|
44
|
+
getStorage()?.setItem(key, JSON.stringify([...next]));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
48
|
+
}
|
|
49
|
+
notify(key);
|
|
50
|
+
},
|
|
51
|
+
subscribe(key, listener) {
|
|
52
|
+
let set = listeners.get(key);
|
|
53
|
+
if (!set)
|
|
54
|
+
listeners.set(key, (set = new Set()));
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return () => set.delete(listener);
|
|
57
|
+
},
|
|
58
|
+
changedElsewhere(key) {
|
|
59
|
+
if (!current.has(key))
|
|
60
|
+
return;
|
|
61
|
+
current.delete(key);
|
|
62
|
+
notify(key);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export const foldStore = createFoldStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
67
|
+
let listeningForOtherTabs = false;
|
|
68
|
+
export function listenForOtherTabs() {
|
|
69
|
+
if (listeningForOtherTabs || typeof window === 'undefined')
|
|
70
|
+
return;
|
|
71
|
+
listeningForOtherTabs = true;
|
|
72
|
+
window.addEventListener('storage', (event) => {
|
|
73
|
+
if (event.key !== null)
|
|
74
|
+
foldStore.changedElsewhere(event.key);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `[folded, setFolded]`: the heading ids folded under `foldKey`, shared
|
|
79
|
+
* with everything else given that key. Without a key it's plain state,
|
|
80
|
+
* forgotten with the page.
|
|
81
|
+
*/
|
|
82
|
+
export function useFolds(foldKey) {
|
|
83
|
+
const [local, setLocal] = useState(NONE);
|
|
84
|
+
const subscribe = useCallback((listener) => {
|
|
85
|
+
if (foldKey === undefined)
|
|
86
|
+
return () => { };
|
|
87
|
+
listenForOtherTabs();
|
|
88
|
+
return foldStore.subscribe(foldKey, listener);
|
|
89
|
+
}, [foldKey]);
|
|
90
|
+
const stored = useSyncExternalStore(subscribe, () => (foldKey === undefined ? NONE : foldStore.get(foldKey)), () => NONE);
|
|
91
|
+
const set = useCallback((folded) => (foldKey === undefined ? setLocal(folded) : foldStore.set(foldKey, folded)), [foldKey]);
|
|
92
|
+
return [foldKey === undefined ? local : stored, set];
|
|
93
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
20
20
|
export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
|
|
21
21
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
22
22
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
23
|
+
export { NotesOutline, type NotesOutlineProps } from './components/app/notes-outline.js';
|
|
23
24
|
export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
|
|
24
25
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
25
26
|
export { MultiChoice } from './components/app/multi-choice.js';
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
24
24
|
export { InlineCreate } from './components/app/inline-create.js';
|
|
25
25
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
26
26
|
export { Markdown } from './components/app/markdown.js';
|
|
27
|
+
export { NotesOutline } from './components/app/notes-outline.js';
|
|
27
28
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
28
29
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
29
30
|
export { MultiPick } from './components/app/multi-pick.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** A top-level heading, by its level (1 for `#`) and its words. */
|
|
2
|
+
export type FoldHeadingInput = {
|
|
3
|
+
level: number;
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
/** A top-level heading as folding sees it. */
|
|
7
|
+
export type FoldHeading = FoldHeadingInput & {
|
|
8
|
+
/** Its id in the fold state. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Its index among the text's top-level blocks. */
|
|
11
|
+
index: number;
|
|
12
|
+
/** It has something under it to fold. */
|
|
13
|
+
foldable: boolean;
|
|
14
|
+
/** The ids of the headings it sits under, outermost first. */
|
|
15
|
+
parents: string[];
|
|
16
|
+
/** The index of the first top-level block after its section (the text's length at the end). */
|
|
17
|
+
end: number;
|
|
18
|
+
};
|
|
19
|
+
/** The plan for a text's top-level blocks: which are headings, which are folded away. */
|
|
20
|
+
export type FoldPlan = {
|
|
21
|
+
headings: FoldHeading[];
|
|
22
|
+
/** Indexes of the top-level blocks hidden by a folded heading. */
|
|
23
|
+
hidden: Set<number>;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The plan for a text whose top-level blocks are `blocks`: each one a
|
|
27
|
+
* heading (`{ level, text }`) or `null`. `folded` is the ids folded now.
|
|
28
|
+
*/
|
|
29
|
+
export declare function foldPlan(blocks: readonly (FoldHeadingInput | null)[], folded?: ReadonlySet<string>): FoldPlan;
|
|
30
|
+
export type MdFoldNode = {
|
|
31
|
+
type: string;
|
|
32
|
+
depth?: number;
|
|
33
|
+
value?: string;
|
|
34
|
+
children?: MdFoldNode[];
|
|
35
|
+
};
|
|
36
|
+
/** A heading's words as the editor reads them: its text and inline code (not raw HTML, not image alt text). */
|
|
37
|
+
export declare function mdText(node: MdFoldNode): string;
|
|
38
|
+
/** The top-level blocks of an mdast root, as foldPlan takes them. */
|
|
39
|
+
export declare function mdBlocks(root: MdFoldNode): (FoldHeadingInput | null)[];
|
|
40
|
+
/** The fold plan for a Markdown text, parsed as <Markdown> and the editor parse it. */
|
|
41
|
+
export declare function markdownFoldPlan(markdown: string, folded?: ReadonlySet<string>): FoldPlan;
|
|
42
|
+
/** Every heading with something to fold: Fold all. */
|
|
43
|
+
export declare function allFoldable(plan: FoldPlan): Set<string>;
|
|
44
|
+
/** `folded` with the heading `id` and every heading it sits under opened: a jump to it. */
|
|
45
|
+
export declare function openTo(plan: FoldPlan, folded: ReadonlySet<string>, id: string): Set<string>;
|
|
46
|
+
/** `folded` with every heading whose section holds the top-level block at `index` opened: the caret moved into it. */
|
|
47
|
+
export declare function openAt(plan: FoldPlan, folded: ReadonlySet<string>, index: number): Set<string>;
|
|
48
|
+
/** `folded` with `id` turned the other way. */
|
|
49
|
+
export declare function toggleFold(folded: ReadonlySet<string>, id: string): Set<string>;
|
|
50
|
+
/**
|
|
51
|
+
* For <Markdown> (react-markdown's rehype plugins): marks each top-level
|
|
52
|
+
* heading with its fold id and state, and hides every top-level block a
|
|
53
|
+
* folded heading covers (`hidden`). Raw HTML at the top level, which
|
|
54
|
+
* <Markdown> shows as its characters, is hidden as a paragraph of the same
|
|
55
|
+
* characters. GFM's footnotes, gathered at the end, are never hidden.
|
|
56
|
+
*/
|
|
57
|
+
export declare function rehypeFold(options: {
|
|
58
|
+
folded: ReadonlySet<string>;
|
|
59
|
+
}): (tree: unknown) => void;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Folding a text by its headings (`foldKey` on <Markdown> and the
|
|
2
|
+
// rich-text editor, and NotesOutline beside them). A heading at the top
|
|
3
|
+
// level of the text folds everything after it up to the next heading of
|
|
4
|
+
// its level or higher: a `##` folds its paragraphs and its `###`s, and
|
|
5
|
+
// stops at the next `##` or `#`. Headings inside a quote or a callout
|
|
6
|
+
// don't fold.
|
|
7
|
+
//
|
|
8
|
+
// Folding is the reader's, never the text's: what's folded is a set of
|
|
9
|
+
// heading ids, kept on the device (src/hooks/fold-store.ts), and the
|
|
10
|
+
// Markdown is never touched. A heading's id is its level and its words
|
|
11
|
+
// (`2:Safety`), with `#2`, `#3` added for the second and third heading of
|
|
12
|
+
// the same level and words, so the same heading gets the same id in
|
|
13
|
+
// <Markdown>, in the editor and in the outline, and keeps it while the
|
|
14
|
+
// text around it changes. Renaming a heading opens it.
|
|
15
|
+
import remarkGfm from 'remark-gfm';
|
|
16
|
+
import remarkParse from 'remark-parse';
|
|
17
|
+
import { unified } from 'unified';
|
|
18
|
+
function words(text) {
|
|
19
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The plan for a text whose top-level blocks are `blocks`: each one a
|
|
23
|
+
* heading (`{ level, text }`) or `null`. `folded` is the ids folded now.
|
|
24
|
+
*/
|
|
25
|
+
export function foldPlan(blocks, folded = new Set()) {
|
|
26
|
+
const headings = [];
|
|
27
|
+
const hidden = new Set();
|
|
28
|
+
const seen = new Map();
|
|
29
|
+
// The headings the current block sits under, outermost first.
|
|
30
|
+
const open = [];
|
|
31
|
+
blocks.forEach((block, index) => {
|
|
32
|
+
if (block)
|
|
33
|
+
while (open.length > 0 && open[open.length - 1].level >= block.level)
|
|
34
|
+
open.pop().end = index;
|
|
35
|
+
if (open.some((heading) => folded.has(heading.id)))
|
|
36
|
+
hidden.add(index);
|
|
37
|
+
// Anything after a heading, before one of its level or higher, is its section.
|
|
38
|
+
if (open.length > 0)
|
|
39
|
+
open[open.length - 1].foldable = true;
|
|
40
|
+
if (!block)
|
|
41
|
+
return;
|
|
42
|
+
const text = words(block.text);
|
|
43
|
+
const base = `${block.level}:${text}`;
|
|
44
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
45
|
+
seen.set(base, count);
|
|
46
|
+
const heading = {
|
|
47
|
+
level: block.level,
|
|
48
|
+
text,
|
|
49
|
+
id: count === 1 ? base : `${base}#${count}`,
|
|
50
|
+
index,
|
|
51
|
+
foldable: false,
|
|
52
|
+
parents: open.map((one) => one.id),
|
|
53
|
+
end: blocks.length,
|
|
54
|
+
};
|
|
55
|
+
headings.push(heading);
|
|
56
|
+
open.push(heading);
|
|
57
|
+
});
|
|
58
|
+
return { headings, hidden };
|
|
59
|
+
}
|
|
60
|
+
/** A heading's words as the editor reads them: its text and inline code (not raw HTML, not image alt text). */
|
|
61
|
+
export function mdText(node) {
|
|
62
|
+
if (node.type === 'text' || node.type === 'inlineCode')
|
|
63
|
+
return node.value ?? '';
|
|
64
|
+
if (node.type === 'break')
|
|
65
|
+
return ' ';
|
|
66
|
+
return (node.children ?? []).map(mdText).join('');
|
|
67
|
+
}
|
|
68
|
+
/** The top-level blocks of an mdast root, as foldPlan takes them. */
|
|
69
|
+
export function mdBlocks(root) {
|
|
70
|
+
return (root.children ?? []).map((node) => node.type === 'heading' && node.depth ? { level: node.depth, text: mdText(node) } : null);
|
|
71
|
+
}
|
|
72
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
73
|
+
/** The fold plan for a Markdown text, parsed as <Markdown> and the editor parse it. */
|
|
74
|
+
export function markdownFoldPlan(markdown, folded) {
|
|
75
|
+
return foldPlan(mdBlocks(parser.parse(markdown)), folded);
|
|
76
|
+
}
|
|
77
|
+
/** Every heading with something to fold: Fold all. */
|
|
78
|
+
export function allFoldable(plan) {
|
|
79
|
+
return new Set(plan.headings.filter((heading) => heading.foldable).map((heading) => heading.id));
|
|
80
|
+
}
|
|
81
|
+
/** `folded` with the heading `id` and every heading it sits under opened: a jump to it. */
|
|
82
|
+
export function openTo(plan, folded, id) {
|
|
83
|
+
const heading = plan.headings.find((one) => one.id === id);
|
|
84
|
+
const next = new Set(folded);
|
|
85
|
+
if (!heading)
|
|
86
|
+
return next;
|
|
87
|
+
for (const parent of heading.parents)
|
|
88
|
+
next.delete(parent);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
/** `folded` with every heading whose section holds the top-level block at `index` opened: the caret moved into it. */
|
|
92
|
+
export function openAt(plan, folded, index) {
|
|
93
|
+
const next = new Set(folded);
|
|
94
|
+
for (const heading of plan.headings)
|
|
95
|
+
if (heading.index < index && index < heading.end)
|
|
96
|
+
next.delete(heading.id);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
/** `folded` with `id` turned the other way. */
|
|
100
|
+
export function toggleFold(folded, id) {
|
|
101
|
+
const next = new Set(folded);
|
|
102
|
+
if (next.has(id))
|
|
103
|
+
next.delete(id);
|
|
104
|
+
else
|
|
105
|
+
next.add(id);
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
function hastText(node) {
|
|
109
|
+
if (node.type === 'text')
|
|
110
|
+
return node.value ?? '';
|
|
111
|
+
if (node.type === 'element' && node.tagName === 'br')
|
|
112
|
+
return ' ';
|
|
113
|
+
if (node.type !== 'element')
|
|
114
|
+
return '';
|
|
115
|
+
return (node.children ?? []).map(hastText).join('');
|
|
116
|
+
}
|
|
117
|
+
const HEADING = /^h([1-6])$/;
|
|
118
|
+
/**
|
|
119
|
+
* For <Markdown> (react-markdown's rehype plugins): marks each top-level
|
|
120
|
+
* heading with its fold id and state, and hides every top-level block a
|
|
121
|
+
* folded heading covers (`hidden`). Raw HTML at the top level, which
|
|
122
|
+
* <Markdown> shows as its characters, is hidden as a paragraph of the same
|
|
123
|
+
* characters. GFM's footnotes, gathered at the end, are never hidden.
|
|
124
|
+
*/
|
|
125
|
+
export function rehypeFold(options) {
|
|
126
|
+
return (tree) => {
|
|
127
|
+
const root = tree;
|
|
128
|
+
const children = root.children ?? [];
|
|
129
|
+
// The blocks: the root's elements and raw HTML; the line breaks between them aren't.
|
|
130
|
+
const at = [];
|
|
131
|
+
children.forEach((node, index) => {
|
|
132
|
+
if (node.type === 'element' || node.type === 'raw')
|
|
133
|
+
at.push(index);
|
|
134
|
+
});
|
|
135
|
+
const blocks = at.map((index) => {
|
|
136
|
+
const node = children[index];
|
|
137
|
+
const level = node.type === 'element' ? HEADING.exec(node.tagName ?? '')?.[1] : undefined;
|
|
138
|
+
return level ? { level: Number(level), text: hastText(node) } : null;
|
|
139
|
+
});
|
|
140
|
+
const plan = foldPlan(blocks, options.folded);
|
|
141
|
+
for (const heading of plan.headings) {
|
|
142
|
+
const node = children[at[heading.index]];
|
|
143
|
+
node.properties = {
|
|
144
|
+
...node.properties,
|
|
145
|
+
dataFoldId: heading.id,
|
|
146
|
+
dataFoldLabel: heading.text,
|
|
147
|
+
dataFoldable: String(heading.foldable),
|
|
148
|
+
dataFolded: String(heading.foldable && options.folded.has(heading.id)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
for (const block of plan.hidden) {
|
|
152
|
+
const index = at[block];
|
|
153
|
+
let node = children[index];
|
|
154
|
+
if (node.properties?.dataFootnotes !== undefined)
|
|
155
|
+
continue;
|
|
156
|
+
if (node.type === 'raw') {
|
|
157
|
+
node = { type: 'element', tagName: 'p', properties: {}, children: [{ type: 'text', value: node.value ?? '' }] };
|
|
158
|
+
children[index] = node;
|
|
159
|
+
}
|
|
160
|
+
node.properties = { ...node.properties, hidden: true };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.54.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": {
|