@tristan2828/ui-foundation 3.14.0 → 3.16.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/add-an-entity.md +3 -1
- package/conventions/docs/cell-patterns.md +9 -1
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +248 -17
- package/dist/components/app/rich-text-editor.d.ts +14 -0
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/components/app/rich-text-formats.d.ts +12 -0
- package/dist/components/app/rich-text-formats.js +125 -0
- package/dist/components/app/rich-text-toolbar.d.ts +12 -0
- package/dist/components/app/rich-text-toolbar.js +98 -0
- package/dist/components/ui/popover.d.ts +1 -1
- package/dist/components/ui/popover.js +6 -2
- package/dist/lib/link-href.d.ts +2 -0
- package/dist/lib/link-href.js +22 -0
- package/package.json +1 -1
|
@@ -197,7 +197,9 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
197
197
|
the plan edits in place** (`Edit in place`), shared by the form
|
|
198
198
|
(through a `Controller`) and the view (through `editInPlace`), and
|
|
199
199
|
the Markdown long text's `RichTextEditor` (on the form whether or
|
|
200
|
-
not it edits in place)
|
|
200
|
+
not it edits in place), given a `placeholder` and `maxLength` (the
|
|
201
|
+
schema's limit, one constant both read, like Widget's
|
|
202
|
+
`DESCRIPTION_MAX_LENGTH`). Every other field's control stays inline in
|
|
201
203
|
the form, as `widget-form.tsx`'s Category, Tags and Available From
|
|
202
204
|
do. No `Edit in place` line and no Markdown long text: no fields
|
|
203
205
|
file.
|
|
@@ -736,7 +736,15 @@ const price = editInPlace({
|
|
|
736
736
|
- **Long text written as Markdown edits as rich text** (`RichTextEditor`):
|
|
737
737
|
formatted where it's read, saved as Markdown with every block the person
|
|
738
738
|
didn't touch kept exactly as written. On the form too, so it's one
|
|
739
|
-
control.
|
|
739
|
+
control. Give it a `placeholder` and the schema's limit as `maxLength`
|
|
740
|
+
(a count shows from 80% of it, in the destructive text tone once over).
|
|
741
|
+
Its keys: Ctrl/Cmd+K adds or edits a link (web and email addresses
|
|
742
|
+
only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
|
|
743
|
+
Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
|
|
744
|
+
the save. A formatting toolbar sits above the text (bold to link, each
|
|
745
|
+
button naming its shortcut); it's part of the field, so moving to it
|
|
746
|
+
doesn't save. `toolbar={false}` leaves it out where a value is short
|
|
747
|
+
and the shortcuts are enough.
|
|
740
748
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
741
749
|
`optimistic: false, toastOnError: false`, the entity's
|
|
742
750
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -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, 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, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// The rich-text editor behind RichTextEditor, loaded only when one opens
|
|
3
3
|
// (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
|
|
4
4
|
// editor whose Markdown goes in and out through remark, the same parser
|
|
@@ -15,16 +15,33 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
15
15
|
// - Headings render from <h3>, like <Markdown>: the page owns <h1> and
|
|
16
16
|
// <h2>. A task-list item renders a real checkbox that toggles it.
|
|
17
17
|
// - Edited blocks are written in the document's own bullet and rule marks.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// - Markdown pasted as plain text arrives formatted (still no HTML), and a
|
|
19
|
+
// copy out of the editor is Markdown.
|
|
20
|
+
// - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
|
|
21
|
+
// addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
|
|
22
|
+
// in. Ctrl/Cmd+Enter stays the field's save.
|
|
23
|
+
// - A placeholder in an empty document, and a count of the Markdown's
|
|
24
|
+
// length as it nears `maxLength`.
|
|
25
|
+
// - A fixed toolbar above the text (rich-text-toolbar.tsx), unless
|
|
26
|
+
// `toolbar={false}`.
|
|
27
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
28
|
+
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
20
29
|
import { history } from '@milkdown/kit/plugin/history';
|
|
21
30
|
import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
|
|
22
31
|
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
23
32
|
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
24
33
|
import { Plugin } from '@milkdown/kit/prose/state';
|
|
34
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
25
35
|
import { $prose, getMarkdown } from '@milkdown/kit/utils';
|
|
26
36
|
import { cn } from 'cn';
|
|
37
|
+
import { Button } from '../ui/button.js';
|
|
38
|
+
import { Field, FieldError, FieldLabel } from '../ui/field.js';
|
|
39
|
+
import { Input } from '../ui/input.js';
|
|
40
|
+
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
41
|
+
import { linkHref } from '../../lib/link-href.js';
|
|
27
42
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
43
|
+
import { toggleFormat } from './rich-text-formats.js';
|
|
44
|
+
import { RichTextToolbar } from './rich-text-toolbar.js';
|
|
28
45
|
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
29
46
|
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
30
47
|
const LEFT_OUT = remarkPreserveEmptyLinePlugin;
|
|
@@ -102,29 +119,107 @@ function taskItemView(node, view, getPos) {
|
|
|
102
119
|
ignoreMutation: (mutation) => mutation.target === box,
|
|
103
120
|
};
|
|
104
121
|
}
|
|
105
|
-
|
|
122
|
+
// The task-list item the caret is in, ticked or unticked. False when the
|
|
123
|
+
// caret isn't in one.
|
|
124
|
+
function toggleTaskItem(view) {
|
|
125
|
+
const { $from } = view.state.selection;
|
|
126
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
127
|
+
const node = $from.node(depth);
|
|
128
|
+
if (node.type.name === 'list_item' && node.attrs.checked != null) {
|
|
129
|
+
view.dispatch(view.state.tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, checked: !node.attrs.checked }));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function linkTarget(state) {
|
|
136
|
+
const link = state.schema.marks.link;
|
|
137
|
+
const { from, to, empty, $from } = state.selection;
|
|
138
|
+
if (!empty) {
|
|
139
|
+
let href = '';
|
|
140
|
+
state.doc.nodesBetween(from, to, (node) => {
|
|
141
|
+
const mark = link.isInSet(node.marks);
|
|
142
|
+
if (mark && !href)
|
|
143
|
+
href = String(mark.attrs.href);
|
|
144
|
+
});
|
|
145
|
+
return { from, to, href };
|
|
146
|
+
}
|
|
147
|
+
const mark = link.isInSet($from.marks());
|
|
148
|
+
if (!mark)
|
|
149
|
+
return { from, to, href: '' };
|
|
150
|
+
const start = $from.start();
|
|
151
|
+
let target = { from, to, href: String(mark.attrs.href) };
|
|
152
|
+
let runFrom = -1;
|
|
153
|
+
$from.parent.forEach((child, offset) => {
|
|
154
|
+
const childFrom = start + offset;
|
|
155
|
+
const childTo = childFrom + child.nodeSize;
|
|
156
|
+
if (!mark.isInSet(child.marks)) {
|
|
157
|
+
runFrom = -1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (runFrom < 0)
|
|
161
|
+
runFrom = childFrom;
|
|
162
|
+
if (runFrom <= from && from <= childTo)
|
|
163
|
+
target = { from: runFrom, to: childTo, href: target.href };
|
|
164
|
+
});
|
|
165
|
+
return target;
|
|
166
|
+
}
|
|
167
|
+
// Sets (or, with `href` empty, removes) the link on the target. With
|
|
168
|
+
// nothing selected and no link there, the address goes in as the link's
|
|
169
|
+
// own text.
|
|
170
|
+
function applyLink(view, target, href) {
|
|
171
|
+
const link = view.state.schema.marks.link;
|
|
172
|
+
const tr = view.state.tr;
|
|
173
|
+
if (target.from === target.to) {
|
|
174
|
+
if (!href)
|
|
175
|
+
return;
|
|
176
|
+
tr.insertText(href, target.from).addMark(target.from, target.from + href.length, link.create({ href }));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
tr.removeMark(target.from, target.to, link);
|
|
180
|
+
if (href)
|
|
181
|
+
tr.addMark(target.from, target.to, link.create({ href }));
|
|
182
|
+
}
|
|
183
|
+
view.dispatch(tr.scrollIntoView());
|
|
184
|
+
}
|
|
185
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
106
186
|
const rootRef = useRef(null);
|
|
107
187
|
const editorRef = useRef(null);
|
|
188
|
+
const [linkBox, setLinkBox] = useState(null);
|
|
189
|
+
const [length, setLength] = useState(defaultValue.length);
|
|
190
|
+
// The editor's state after each change, for the toolbar's pressed buttons.
|
|
191
|
+
const [editorState, setEditorState] = useState(null);
|
|
192
|
+
// Opens the link box: made with the editor, used by the toolbar's Link.
|
|
193
|
+
const openLinkRef = useRef(() => { });
|
|
108
194
|
// The latest props, for the editor's callbacks, which are set up once.
|
|
109
|
-
const latest = useRef({ onChange, onProblem, readOnly });
|
|
195
|
+
const latest = useRef({ onChange, onProblem, readOnly, placeholder });
|
|
110
196
|
useEffect(() => {
|
|
111
|
-
latest.current = { onChange, onProblem, readOnly };
|
|
197
|
+
latest.current = { onChange, onProblem, readOnly, placeholder };
|
|
112
198
|
});
|
|
199
|
+
const ids = useId();
|
|
200
|
+
const counterId = `${ids}-count`;
|
|
201
|
+
// The editable element's id: the app's, or one of its own, so the
|
|
202
|
+
// toolbar can name what it controls.
|
|
203
|
+
const editableId = id ?? `${ids}-text`;
|
|
204
|
+
// The count shows from 80% of the limit: below that it's noise.
|
|
205
|
+
const showCount = maxLength !== undefined && length >= maxLength * 0.8;
|
|
206
|
+
const describedBy = [ariaDescribedBy, showCount ? counterId : undefined].filter(Boolean).join(' ');
|
|
113
207
|
const attributes = {
|
|
114
208
|
'aria-multiline': 'true',
|
|
115
209
|
'aria-readonly': String(readOnly),
|
|
116
210
|
class: 'outline-none',
|
|
117
211
|
};
|
|
118
|
-
|
|
119
|
-
attributes.id = id;
|
|
212
|
+
attributes.id = editableId;
|
|
120
213
|
if (ariaLabel)
|
|
121
214
|
attributes['aria-label'] = ariaLabel;
|
|
122
215
|
if (ariaLabelledBy)
|
|
123
216
|
attributes['aria-labelledby'] = ariaLabelledBy;
|
|
124
|
-
if (
|
|
125
|
-
attributes['aria-describedby'] =
|
|
217
|
+
if (describedBy)
|
|
218
|
+
attributes['aria-describedby'] = describedBy;
|
|
126
219
|
if (ariaInvalid)
|
|
127
220
|
attributes['aria-invalid'] = 'true';
|
|
221
|
+
if (placeholder)
|
|
222
|
+
attributes['aria-placeholder'] = placeholder;
|
|
128
223
|
const attributesKey = JSON.stringify(attributes);
|
|
129
224
|
// The editor is made once, from the Markdown it opened with.
|
|
130
225
|
useEffect(() => {
|
|
@@ -143,6 +238,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
143
238
|
const merged = mergeMarkdown(original, baseline, current);
|
|
144
239
|
latest.current.onProblem?.(null);
|
|
145
240
|
latest.current.onChange(merged);
|
|
241
|
+
setLength(merged.length);
|
|
146
242
|
}
|
|
147
243
|
catch (error) {
|
|
148
244
|
if (!(error instanceof MarkdownMergeError))
|
|
@@ -153,6 +249,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
153
249
|
const changes = $prose(() => new Plugin({
|
|
154
250
|
view: () => ({
|
|
155
251
|
update: (view, previous) => {
|
|
252
|
+
setEditorState(view.state);
|
|
156
253
|
if (!view.state.doc.eq(previous.doc))
|
|
157
254
|
emit();
|
|
158
255
|
},
|
|
@@ -161,6 +258,81 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
161
258
|
transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
|
|
162
259
|
},
|
|
163
260
|
}));
|
|
261
|
+
// Opens the link box at the start of what the link applies to.
|
|
262
|
+
const openLink = (view) => {
|
|
263
|
+
if (!view.editable)
|
|
264
|
+
return;
|
|
265
|
+
const target = linkTarget(view.state);
|
|
266
|
+
const point = view.coordsAtPos(target.from);
|
|
267
|
+
setLinkBox({
|
|
268
|
+
...target,
|
|
269
|
+
anchor: {
|
|
270
|
+
getBoundingClientRect: () => new DOMRect(point.left, point.top, 0, point.bottom - point.top),
|
|
271
|
+
contextElement: view.dom,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
openLinkRef.current = openLink;
|
|
276
|
+
// Paste, copy, the editor's own keys and the placeholder.
|
|
277
|
+
const input = $prose((ctx) => new Plugin({
|
|
278
|
+
props: {
|
|
279
|
+
// Plain text is read as Markdown, so `## Notes` or `**bold**`
|
|
280
|
+
// copied from another app arrives formatted; raw HTML in it
|
|
281
|
+
// still arrives as text. A paste with HTML of its own (from a
|
|
282
|
+
// web page) is ProseMirror's, through transformPasted above.
|
|
283
|
+
handlePaste: (view, event) => {
|
|
284
|
+
const data = event.clipboardData;
|
|
285
|
+
if (!data || !view.editable || data.getData('text/html'))
|
|
286
|
+
return false;
|
|
287
|
+
const text = data.getData('text/plain');
|
|
288
|
+
if (!text || view.state.selection.$from.parent.type.spec.code)
|
|
289
|
+
return false;
|
|
290
|
+
const parsed = ctx.get(parserCtx)(text);
|
|
291
|
+
if (!parsed || typeof parsed === 'string')
|
|
292
|
+
return false;
|
|
293
|
+
// Text with no Markdown in it (one plain paragraph) goes in
|
|
294
|
+
// exactly as typed, spaces at its ends kept.
|
|
295
|
+
const only = parsed.childCount === 1 ? parsed.firstChild : null;
|
|
296
|
+
if (only?.type.name === 'paragraph' && only.childCount === 1 && only.textContent === text.trim()) {
|
|
297
|
+
view.dispatch(view.state.tr.insertText(text).scrollIntoView());
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
view.dispatch(view.state.tr.replaceSelection(Slice.maxOpen(htmlToText(parsed.content))).scrollIntoView());
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
// A copy is Markdown, so it pastes into a plain text field
|
|
304
|
+
// (or another Markdown editor) as written.
|
|
305
|
+
clipboardTextSerializer: (slice) => {
|
|
306
|
+
const doc = slice.content.firstChild?.isBlock
|
|
307
|
+
? ctx.get(schemaCtx).topNodeType.createAndFill(undefined, slice.content)
|
|
308
|
+
: null;
|
|
309
|
+
return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
|
|
310
|
+
},
|
|
311
|
+
handleKeyDown: (view, event) => {
|
|
312
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
313
|
+
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
openLink(view);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
// Ctrl/Cmd+Shift+Enter in a task item ticks it, and goes no
|
|
319
|
+
// further. Anywhere else it reaches the field, which saves.
|
|
320
|
+
if (mod && event.shiftKey && event.key === 'Enter' && view.editable && toggleTaskItem(view)) {
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
event.stopPropagation();
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
},
|
|
327
|
+
decorations: (state) => {
|
|
328
|
+
const text = latest.current.placeholder;
|
|
329
|
+
const only = state.doc.childCount === 1 ? state.doc.firstChild : null;
|
|
330
|
+
if (!text || !only || only.type.name !== 'paragraph' || only.content.size > 0)
|
|
331
|
+
return null;
|
|
332
|
+
return DecorationSet.create(state.doc, [Decoration.node(0, only.nodeSize, { 'data-placeholder': text })]);
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
}));
|
|
164
336
|
const make = Editor.make()
|
|
165
337
|
.config((ctx) => {
|
|
166
338
|
ctx.set(rootCtx, root);
|
|
@@ -179,7 +351,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
179
351
|
.use(COMMONMARK)
|
|
180
352
|
.use(gfm)
|
|
181
353
|
.use(history)
|
|
182
|
-
.use(changes)
|
|
354
|
+
.use(changes)
|
|
355
|
+
.use(input);
|
|
183
356
|
void make.create().then((editor) => {
|
|
184
357
|
if (cancelled) {
|
|
185
358
|
void editor.destroy();
|
|
@@ -187,6 +360,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
187
360
|
}
|
|
188
361
|
editorRef.current = editor;
|
|
189
362
|
baseline = editor.action(getMarkdown());
|
|
363
|
+
setEditorState(editor.action((ctx) => ctx.get(editorViewCtx).state));
|
|
190
364
|
if (autoFocus)
|
|
191
365
|
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
192
366
|
});
|
|
@@ -212,10 +386,67 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
212
386
|
view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
|
|
213
387
|
});
|
|
214
388
|
}, [readOnly, attributesKey]);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
389
|
+
// A toolbar button: its format on the selection, then focus back in the
|
|
390
|
+
// text (Link opens the link box instead, which gives it back on close).
|
|
391
|
+
const runFormat = (format) => {
|
|
392
|
+
editorRef.current?.action((ctx) => {
|
|
393
|
+
const view = ctx.get(editorViewCtx);
|
|
394
|
+
if (format === 'link') {
|
|
395
|
+
openLinkRef.current(view);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
toggleFormat(view.state, view.dispatch, format);
|
|
399
|
+
view.focus();
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
const closeLink = () => {
|
|
403
|
+
setLinkBox(null);
|
|
404
|
+
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
405
|
+
};
|
|
406
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [toolbar && (_jsx(RichTextToolbar, { state: editorState, readOnly: readOnly, controls: editableId, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
407
|
+
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
408
|
+
'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
409
|
+
// The same type roles <Markdown> uses, so text reads the same
|
|
410
|
+
// being edited as being read.
|
|
411
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
412
|
+
// Raw HTML from the text: shown as its characters, never as markup.
|
|
413
|
+
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
|
|
414
|
+
// The placeholder, in an empty document only (a decoration).
|
|
415
|
+
'[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]') })] }), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
|
|
416
|
+
? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
|
|
417
|
+
: `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
|
|
418
|
+
const editor = editorRef.current;
|
|
419
|
+
editor?.action((ctx) => applyLink(ctx.get(editorViewCtx), linkBox, href));
|
|
420
|
+
closeLink();
|
|
421
|
+
} })) })] }));
|
|
422
|
+
}
|
|
423
|
+
// The link box: the address, Apply, and Remove link for an existing one.
|
|
424
|
+
// Enter (Ctrl/Cmd+Enter too) applies the link and stays its own: editing
|
|
425
|
+
// in place listens for Ctrl/Cmd+Enter through the portal, and would save
|
|
426
|
+
// the field without it. Esc is the popover's: it closes the box only.
|
|
427
|
+
function LinkForm({ box, onApply }) {
|
|
428
|
+
const [typed, setTyped] = useState(box.href);
|
|
429
|
+
const [error, setError] = useState(null);
|
|
430
|
+
const inputRef = useRef(null);
|
|
431
|
+
const inputId = useId();
|
|
432
|
+
const errorId = `${inputId}-error`;
|
|
433
|
+
const apply = () => {
|
|
434
|
+
const href = linkHref(typed);
|
|
435
|
+
if (href === null) {
|
|
436
|
+
setError('Enter a web or email address, like https://example.com.');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
onApply(href);
|
|
440
|
+
};
|
|
441
|
+
const onKeyDown = (event) => {
|
|
442
|
+
if (event.key === 'Enter' && event.target === inputRef.current) {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
event.stopPropagation();
|
|
445
|
+
apply();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
return (_jsxs(PopoverContent, { anchor: box.anchor, align: "start", "aria-label": box.href ? 'Edit link' : 'Add link', initialFocus: inputRef, finalFocus: false, className: "w-80", onKeyDown: onKeyDown, children: [_jsxs(Field, { "data-invalid": error ? true : undefined, children: [_jsx(FieldLabel, { htmlFor: inputId, children: "Link address" }), _jsx(Input, { ref: inputRef, id: inputId, value: typed, placeholder: "https://", autoComplete: "off", "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined, onChange: (event) => {
|
|
449
|
+
setTyped(event.target.value);
|
|
450
|
+
setError(null);
|
|
451
|
+
} }), error && _jsx(FieldError, { id: errorId, children: error })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", onClick: apply, children: "Apply" }), box.href && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => onApply(''), children: "Remove link" }))] })] }));
|
|
221
452
|
}
|
|
@@ -10,6 +10,20 @@ export type RichTextEditorProps = {
|
|
|
10
10
|
/** Shown but not editable (while a save is in flight). */
|
|
11
11
|
readOnly?: boolean;
|
|
12
12
|
autoFocus?: boolean;
|
|
13
|
+
/** Shown while the document is empty (and given to screen readers as `aria-placeholder`). */
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The field's limit on the saved Markdown's length. From 80% of it, a
|
|
17
|
+
* count shows under the editor, in the destructive text tone once over.
|
|
18
|
+
* Typing isn't stopped: the form's schema still refuses a save over it.
|
|
19
|
+
*/
|
|
20
|
+
maxLength?: number;
|
|
21
|
+
/**
|
|
22
|
+
* The formatting toolbar above the text (bold, headings, lists, link, …),
|
|
23
|
+
* each button with its shortcut. On by default; `false` leaves the text
|
|
24
|
+
* alone, its shortcuts still working.
|
|
25
|
+
*/
|
|
26
|
+
toolbar?: boolean;
|
|
13
27
|
className?: string;
|
|
14
28
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
15
29
|
'aria-label'?: string;
|
|
@@ -21,5 +21,5 @@ import { cn } from 'cn';
|
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
22
|
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
23
|
export function RichTextEditor(props) {
|
|
24
|
-
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex
|
|
24
|
+
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col rounded-lg border border-input', props.className), children: [props.toolbar !== false && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: "flex min-h-24 flex-col gap-2 px-2.5 py-2", children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
|
|
25
25
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
|
|
2
|
+
export type FormatId = 'bold' | 'italic' | 'strike' | 'code' | 'heading' | 'subheading' | 'bullets' | 'numbers' | 'tasks' | 'quote' | 'codeBlock' | 'link';
|
|
3
|
+
type Dispatch = (tr: Transaction) => void;
|
|
4
|
+
/** Whether a format is on where the caret is. */
|
|
5
|
+
export declare function formatOn(state: EditorState, id: FormatId): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Turns a format on, or off when it's already on, where the caret is.
|
|
8
|
+
* Returns false when it can't apply there. `link` isn't run here: the
|
|
9
|
+
* toolbar opens the link box for it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toggleFormat(state: EditorState, dispatch: Dispatch, id: FormatId): boolean;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// What the rich-text editor's toolbar can do: each format, whether it's on
|
|
2
|
+
// where the caret (or the selection) is, and how to turn it on or off.
|
|
3
|
+
// Plain ProseMirror commands over Milkdown's schema (commonmark + gfm), so
|
|
4
|
+
// a toolbar press is one undo step, the same as its keyboard shortcut.
|
|
5
|
+
import { lift, setBlockType, toggleMark, wrapIn } from '@milkdown/kit/prose/commands';
|
|
6
|
+
import { liftListItem, wrapInList } from '@milkdown/kit/prose/schema-list';
|
|
7
|
+
const MARKS = {
|
|
8
|
+
bold: 'strong',
|
|
9
|
+
italic: 'emphasis',
|
|
10
|
+
strike: 'strike_through',
|
|
11
|
+
code: 'inlineCode',
|
|
12
|
+
link: 'link',
|
|
13
|
+
};
|
|
14
|
+
function markOn(state, type) {
|
|
15
|
+
const { from, to, empty, $from } = state.selection;
|
|
16
|
+
if (empty)
|
|
17
|
+
return Boolean(type.isInSet(state.storedMarks ?? $from.marks()));
|
|
18
|
+
return state.doc.rangeHasMark(from, to, type);
|
|
19
|
+
}
|
|
20
|
+
// The innermost ancestor of the caret matching `test`, and its position.
|
|
21
|
+
function ancestor(state, test) {
|
|
22
|
+
const { $from } = state.selection;
|
|
23
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
24
|
+
const node = $from.node(depth);
|
|
25
|
+
if (test(node))
|
|
26
|
+
return { node, pos: $from.before(depth) };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const isList = (node) => node.type.name === 'bullet_list' || node.type.name === 'ordered_list';
|
|
31
|
+
/** Whether a format is on where the caret is. */
|
|
32
|
+
export function formatOn(state, id) {
|
|
33
|
+
const { schema } = state;
|
|
34
|
+
const mark = MARKS[id];
|
|
35
|
+
if (mark)
|
|
36
|
+
return markOn(state, schema.marks[mark]);
|
|
37
|
+
const parent = state.selection.$from.parent;
|
|
38
|
+
switch (id) {
|
|
39
|
+
case 'heading':
|
|
40
|
+
case 'subheading':
|
|
41
|
+
return parent.type.name === 'heading' && parent.attrs.level === (id === 'heading' ? 1 : 2);
|
|
42
|
+
case 'codeBlock':
|
|
43
|
+
return parent.type.name === 'code_block';
|
|
44
|
+
case 'quote':
|
|
45
|
+
return ancestor(state, (node) => node.type.name === 'blockquote') !== null;
|
|
46
|
+
default: {
|
|
47
|
+
const list = ancestor(state, isList);
|
|
48
|
+
const item = ancestor(state, (node) => node.type.name === 'list_item');
|
|
49
|
+
if (!list || !item)
|
|
50
|
+
return false;
|
|
51
|
+
const task = item.node.attrs.checked != null;
|
|
52
|
+
if (id === 'tasks')
|
|
53
|
+
return task;
|
|
54
|
+
if (id === 'numbers')
|
|
55
|
+
return list.node.type.name === 'ordered_list';
|
|
56
|
+
return list.node.type.name === 'bullet_list' && !task;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// The list the caret is in, turned into `type`, every item a task item or
|
|
61
|
+
// none (`checked`: false for a task, null for not).
|
|
62
|
+
function convertList(tr, type, checked) {
|
|
63
|
+
const $from = tr.selection.$from;
|
|
64
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
65
|
+
const node = $from.node(depth);
|
|
66
|
+
if (!isList(node))
|
|
67
|
+
continue;
|
|
68
|
+
const pos = $from.before(depth);
|
|
69
|
+
tr.setNodeMarkup(pos, type);
|
|
70
|
+
node.forEach((item, offset) => {
|
|
71
|
+
const itemPos = pos + 1 + offset;
|
|
72
|
+
const keep = checked === false && item.attrs.checked != null ? item.attrs.checked : checked;
|
|
73
|
+
tr.setNodeMarkup(itemPos, undefined, {
|
|
74
|
+
...item.attrs,
|
|
75
|
+
checked: keep,
|
|
76
|
+
listType: type.name === 'ordered_list' ? 'ordered' : 'bullet',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return tr;
|
|
80
|
+
}
|
|
81
|
+
return tr;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Turns a format on, or off when it's already on, where the caret is.
|
|
85
|
+
* Returns false when it can't apply there. `link` isn't run here: the
|
|
86
|
+
* toolbar opens the link box for it.
|
|
87
|
+
*/
|
|
88
|
+
export function toggleFormat(state, dispatch, id) {
|
|
89
|
+
const { schema } = state;
|
|
90
|
+
const mark = MARKS[id];
|
|
91
|
+
if (mark && id !== 'link')
|
|
92
|
+
return toggleMark(schema.marks[mark])(state, dispatch);
|
|
93
|
+
const on = formatOn(state, id);
|
|
94
|
+
const paragraph = schema.nodes.paragraph;
|
|
95
|
+
switch (id) {
|
|
96
|
+
case 'heading':
|
|
97
|
+
case 'subheading':
|
|
98
|
+
return on
|
|
99
|
+
? setBlockType(paragraph)(state, dispatch)
|
|
100
|
+
: setBlockType(schema.nodes.heading, { level: id === 'heading' ? 1 : 2 })(state, dispatch);
|
|
101
|
+
case 'codeBlock':
|
|
102
|
+
return on ? setBlockType(paragraph)(state, dispatch) : setBlockType(schema.nodes.code_block)(state, dispatch);
|
|
103
|
+
case 'quote':
|
|
104
|
+
return on ? lift(state, dispatch) : wrapIn(schema.nodes.blockquote)(state, dispatch);
|
|
105
|
+
case 'bullets':
|
|
106
|
+
case 'numbers':
|
|
107
|
+
case 'tasks': {
|
|
108
|
+
if (on)
|
|
109
|
+
return liftListItem(schema.nodes.list_item)(state, dispatch);
|
|
110
|
+
const type = id === 'numbers' ? schema.nodes.ordered_list : schema.nodes.bullet_list;
|
|
111
|
+
const checked = id === 'tasks' ? false : null;
|
|
112
|
+
if (ancestor(state, isList)) {
|
|
113
|
+
dispatch(convertList(state.tr, type, checked));
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
let wrapped = null;
|
|
117
|
+
if (!wrapInList(type)(state, (tr) => (wrapped = tr)))
|
|
118
|
+
return false;
|
|
119
|
+
dispatch(convertList(wrapped, type, checked));
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EditorState } from '@milkdown/kit/prose/state';
|
|
2
|
+
import { type FormatId } from './rich-text-formats.js';
|
|
3
|
+
export interface RichTextToolbarProps {
|
|
4
|
+
/** The editor's state now, to show which formats are on. Null until it's made. */
|
|
5
|
+
state: EditorState | null;
|
|
6
|
+
readOnly: boolean;
|
|
7
|
+
/** The editable element's id, for `aria-controls`. */
|
|
8
|
+
controls?: string;
|
|
9
|
+
/** A button pressed: run the format on the editor and give it focus back. */
|
|
10
|
+
onFormat: (id: FormatId) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function RichTextToolbar({ state, readOnly, controls, onFormat }: RichTextToolbarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The rich-text editor's fixed toolbar: one row of pressed/not-pressed
|
|
3
|
+
// buttons above the text, each a format and its keyboard shortcut.
|
|
4
|
+
//
|
|
5
|
+
// - One tab stop (ARIA's toolbar pattern): Tab reaches the last button
|
|
6
|
+
// used, the arrow keys, Home and End move along the row.
|
|
7
|
+
// - A press acts on the editor's selection and gives focus back to the
|
|
8
|
+
// text; a mouse press never takes focus from it, so the selection stays.
|
|
9
|
+
// - Each button says whether its format is on where the caret is
|
|
10
|
+
// (`aria-pressed`), and names its shortcut (`aria-keyshortcuts`, and the
|
|
11
|
+
// tooltip).
|
|
12
|
+
// - Read-only (while a save is in flight), every button is disabled.
|
|
13
|
+
import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
|
|
14
|
+
import { Fragment, useRef, useState } from 'react';
|
|
15
|
+
import { Separator } from '../ui/separator.js';
|
|
16
|
+
import { Toggle } from '../ui/toggle.js';
|
|
17
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
|
|
18
|
+
import { formatOn } from './rich-text-formats.js';
|
|
19
|
+
const GROUPS = [
|
|
20
|
+
[
|
|
21
|
+
{ id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
|
|
22
|
+
{ id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
|
|
23
|
+
{ id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
|
|
24
|
+
{ id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
{ id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
|
|
28
|
+
{ id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
{ id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
|
|
32
|
+
{ id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
|
|
33
|
+
{ id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
{ id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
|
|
37
|
+
{ id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
|
|
38
|
+
],
|
|
39
|
+
[{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
|
|
40
|
+
];
|
|
41
|
+
const ITEMS = GROUPS.flat();
|
|
42
|
+
function isApple() {
|
|
43
|
+
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
44
|
+
}
|
|
45
|
+
// `aria-keyshortcuts` names real keys: Meta on Apple, Control elsewhere.
|
|
46
|
+
function ariaKeys(keys, apple) {
|
|
47
|
+
return keys.replace('Mod', apple ? 'Meta' : 'Control');
|
|
48
|
+
}
|
|
49
|
+
// What a person reads: ⌘⌥X on Apple, Ctrl+Alt+X elsewhere.
|
|
50
|
+
function shownKeys(keys, apple) {
|
|
51
|
+
if (!apple)
|
|
52
|
+
return keys.replace('Mod', 'Ctrl');
|
|
53
|
+
const symbols = { Mod: '⌘', Alt: '⌥', Shift: '⇧', Enter: '↵' };
|
|
54
|
+
return keys
|
|
55
|
+
.split('+')
|
|
56
|
+
.map((part) => symbols[part] ?? part)
|
|
57
|
+
.join('');
|
|
58
|
+
}
|
|
59
|
+
export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
|
|
60
|
+
const apple = isApple();
|
|
61
|
+
const buttons = useRef([]);
|
|
62
|
+
// The one button Tab reaches: the last one used.
|
|
63
|
+
const [current, setCurrent] = useState(0);
|
|
64
|
+
const move = (index) => {
|
|
65
|
+
const next = (index + ITEMS.length) % ITEMS.length;
|
|
66
|
+
setCurrent(next);
|
|
67
|
+
buttons.current[next]?.focus();
|
|
68
|
+
};
|
|
69
|
+
const onKeyDown = (event) => {
|
|
70
|
+
const keys = {
|
|
71
|
+
ArrowRight: () => move(current + 1),
|
|
72
|
+
ArrowLeft: () => move(current - 1),
|
|
73
|
+
Home: () => move(0),
|
|
74
|
+
End: () => move(ITEMS.length - 1),
|
|
75
|
+
};
|
|
76
|
+
const action = keys[event.key];
|
|
77
|
+
if (!action || event.metaKey || event.ctrlKey || event.altKey)
|
|
78
|
+
return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
action();
|
|
81
|
+
};
|
|
82
|
+
return (_jsx(TooltipProvider, { delay: 400, children: _jsx("div", { role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", onKeyDown: onKeyDown, children: GROUPS.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
|
|
83
|
+
const index = ITEMS.indexOf(item);
|
|
84
|
+
const Icon = item.icon;
|
|
85
|
+
const tip = [
|
|
86
|
+
item.label,
|
|
87
|
+
item.keys && `(${shownKeys(item.keys, apple)})`,
|
|
88
|
+
item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(' ');
|
|
92
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
|
|
93
|
+
buttons.current[index] = element;
|
|
94
|
+
}, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
|
|
95
|
+
// A mouse press keeps focus, and the selection, in the text.
|
|
96
|
+
onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
|
|
97
|
+
})] }, groupIndex))) }) }));
|
|
98
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
|
3
3
|
declare function Popover({ ...props }: PopoverPrimitive.Root.Props): React.JSX.Element;
|
|
4
4
|
declare function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props): React.JSX.Element;
|
|
5
|
-
declare function PopoverContent({ className, align, alignOffset, side, sideOffset, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">): React.JSX.Element;
|
|
5
|
+
declare function PopoverContent({ className, align, alignOffset, side, sideOffset, anchor, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "anchor">): React.JSX.Element;
|
|
6
6
|
declare function PopoverHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
7
7
|
declare function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props): React.JSX.Element;
|
|
8
8
|
declare function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props): React.JSX.Element;
|
|
@@ -8,8 +8,12 @@ function Popover({ ...props }) {
|
|
|
8
8
|
function PopoverTrigger({ ...props }) {
|
|
9
9
|
return _jsx(PopoverPrimitive.Trigger, { "data-slot": "popover-trigger", ...props });
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// `anchor` is this repo's patch to the upstream primitive: a popover with
|
|
12
|
+
// no trigger of its own, placed at a point (the rich-text editor's link
|
|
13
|
+
// box, at the caret). A local patch: re-running `shadcn add popover` here
|
|
14
|
+
// would overwrite it. Diff against upstream before taking a new version.
|
|
15
|
+
function PopoverContent({ className, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, anchor, ...props }) {
|
|
16
|
+
return (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Positioner, { anchor: anchor, align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, className: "isolate z-50", children: _jsx(PopoverPrimitive.Popup, { "data-slot": "popover-content", className: cn("z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props }) }) }));
|
|
13
17
|
}
|
|
14
18
|
function PopoverHeader({ className, ...props }) {
|
|
15
19
|
return (_jsx("div", { "data-slot": "popover-header", className: cn("flex flex-col gap-0.5 text-sm", className), ...props }));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The address a link in the rich-text editor points at, from what the
|
|
2
|
+
// person typed. Web and email addresses only: a bare `example.com` becomes
|
|
3
|
+
// `https://example.com` and a bare `name@example.com` a `mailto:`; a path
|
|
4
|
+
// on this site (`/x`) or a heading (`#x`) is kept. Any other scheme
|
|
5
|
+
// (`javascript:`, `data:`, `file:`) is refused, so a link can't run code
|
|
6
|
+
// or reach a local file.
|
|
7
|
+
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
8
|
+
export function linkHref(typed) {
|
|
9
|
+
const value = typed.trim();
|
|
10
|
+
if (value === '')
|
|
11
|
+
return '';
|
|
12
|
+
if (/^(https?:\/\/|mailto:|tel:)/i.test(value))
|
|
13
|
+
return value;
|
|
14
|
+
// A scheme of its own (`javascript:`), not a port (`example.com:8080`).
|
|
15
|
+
if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(value))
|
|
16
|
+
return null;
|
|
17
|
+
if (value.startsWith('/') || value.startsWith('#'))
|
|
18
|
+
return value;
|
|
19
|
+
if (/^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(value))
|
|
20
|
+
return `mailto:${value}`;
|
|
21
|
+
return `https://${value}`;
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.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": {
|