@charcuterie/ui 3.3.0 → 3.4.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/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +19 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.js +47 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.js +30 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/index.js +14 -0
- package/dist/markdownEditorCodeMirror/index.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
- package/package.json +36 -1
- package/src/Field/Field.test.tsx +35 -0
- package/src/Field/Field.tsx +18 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
- package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
- package/src/MarkdownEditor/markdownCommands.ts +60 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
- package/src/MarkdownEditor/markdownSpans.ts +35 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
- package/src/markdownEditorCodeMirror/index.ts +26 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
- package/src/sourceRules.test.ts +20 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → the ranges a live-preview surface decorates, as pure
|
|
3
|
+
* tree-in / descriptors-out.
|
|
4
|
+
*
|
|
5
|
+
* This is the half of the CodeMirror editor that decides what a
|
|
6
|
+
* document *looks* like, and it is deliberately the half with no
|
|
7
|
+
* `@codemirror/view` import in it. Descriptors are plain
|
|
8
|
+
* `{ from, to, type }` objects; turning them into `Decoration`s is
|
|
9
|
+
* `livePreview.ts`'s job. The split buys two things:
|
|
10
|
+
*
|
|
11
|
+
* - **These tests run in Node.** `packages/ui`'s Vitest project is
|
|
12
|
+
* `.ts`-only and has no DOM, on purpose. A test here parses real
|
|
13
|
+
* markdown with the real GFM parser and asserts real offsets —
|
|
14
|
+
* no jsdom, no mount, no story.
|
|
15
|
+
* - **The runtime stays incremental.** Tests hand in
|
|
16
|
+
* `markdownLanguage.parser.parse(text)`; the view hands in
|
|
17
|
+
* `syntaxTree(state)`, which CodeMirror has already reparsed
|
|
18
|
+
* incrementally. Same function, same offsets, and the expensive
|
|
19
|
+
* path is never the one under test.
|
|
20
|
+
*
|
|
21
|
+
* ### What separates this from `markdownSpans.ts`
|
|
22
|
+
*
|
|
23
|
+
* The sibling `MarkdownEditor` paints *behind* a `<textarea>`, so
|
|
24
|
+
* every span it emits has to be metric-neutral — same glyph
|
|
25
|
+
* advances, or the caret shears away from the letters. That is the
|
|
26
|
+
* constraint this module does **not** have. CodeMirror owns the
|
|
27
|
+
* caret and reflows around decorations, so a heading can genuinely
|
|
28
|
+
* be larger, `**` can genuinely be hidden, and an image can
|
|
29
|
+
* genuinely replace its own markup.
|
|
30
|
+
*
|
|
31
|
+
* That is the entire reason this subpath exists, and it is also why
|
|
32
|
+
* it is a subpath: the capability costs ~176 KB gz of optional peer
|
|
33
|
+
* and only a consumer that opts in should pay it.
|
|
34
|
+
*
|
|
35
|
+
* ### The one invariant
|
|
36
|
+
*
|
|
37
|
+
* **Concealment never touches the document.** Every descriptor here
|
|
38
|
+
* describes a *view* over an unmodified markdown string. Hiding
|
|
39
|
+
* `**` does not delete it; replacing `` with an image
|
|
40
|
+
* does not rewrite it. The stored value stays byte-for-byte what
|
|
41
|
+
* the user typed, which is the same no-HTML-in-the-data guarantee
|
|
42
|
+
* the textarea version gets structurally — kept here by never
|
|
43
|
+
* having a serialiser at all.
|
|
44
|
+
*/
|
|
45
|
+
const HEADING_LINE_KINDS = {
|
|
46
|
+
ATXHeading1: "heading1",
|
|
47
|
+
ATXHeading2: "heading2",
|
|
48
|
+
ATXHeading3: "heading3",
|
|
49
|
+
ATXHeading4: "heading4",
|
|
50
|
+
ATXHeading5: "heading5",
|
|
51
|
+
ATXHeading6: "heading6",
|
|
52
|
+
};
|
|
53
|
+
const INLINE_MARK_KINDS = {
|
|
54
|
+
Emphasis: "emphasis",
|
|
55
|
+
InlineCode: "code",
|
|
56
|
+
Strikethrough: "strikethrough",
|
|
57
|
+
StrongEmphasis: "strong",
|
|
58
|
+
};
|
|
59
|
+
/** Marks that vanish with their construct once the caret leaves. */
|
|
60
|
+
const CONCEALABLE_MARK_NAMES = new Set([
|
|
61
|
+
"CodeMark",
|
|
62
|
+
"EmphasisMark",
|
|
63
|
+
"HeaderMark",
|
|
64
|
+
"LinkMark",
|
|
65
|
+
"QuoteMark",
|
|
66
|
+
"StrikethroughMark",
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* A fence is structure, not noise.
|
|
70
|
+
*
|
|
71
|
+
* `CodeMark` covers both `` ` `` and ```` ``` ````, and concealing
|
|
72
|
+
* the fenced kind leaves a blank line at the top and bottom of the
|
|
73
|
+
* block where the fence used to be — which reads as a rendering bug
|
|
74
|
+
* rather than as tidiness. The language tag goes with it, for the
|
|
75
|
+
* same reason Obsidian keeps it: it is the one part of a code block
|
|
76
|
+
* that says what the code *is*.
|
|
77
|
+
*/
|
|
78
|
+
const FENCED_CODE_PARENTS = new Set(["FencedCode"]);
|
|
79
|
+
/**
|
|
80
|
+
* Marks that are always visible, only dimmed.
|
|
81
|
+
*
|
|
82
|
+
* Just the list bullet, and the distinction from `QuoteMark` is the
|
|
83
|
+
* interesting part. A blockquote gets a line decoration — an accent
|
|
84
|
+
* bar down its inline start — so the `>` is *redundant* once that
|
|
85
|
+
* bar is drawn, and Obsidian hides it for exactly that reason. A
|
|
86
|
+
* bullet has no such affordance: hide the `-` and an unordered list
|
|
87
|
+
* becomes indented prose. Obsidian substitutes a `•` glyph there,
|
|
88
|
+
* which means choosing a width, and this module has no business
|
|
89
|
+
* choosing one.
|
|
90
|
+
*
|
|
91
|
+
* So the rule is not "block marks persist" — it is **a mark
|
|
92
|
+
* persists unless something else already says what it said.**
|
|
93
|
+
*/
|
|
94
|
+
const PERSISTENT_MARK_NAMES = new Set(["ListMark"]);
|
|
95
|
+
const isInside = (selections, from, to) => selections.some((selection) => selection.from <= to && selection.to >= from);
|
|
96
|
+
/** The start offset of the line containing `offset`. */
|
|
97
|
+
const toLineStart = (text, offset) => {
|
|
98
|
+
const previousBreak = text.lastIndexOf("\n", offset - 1);
|
|
99
|
+
return previousBreak + 1;
|
|
100
|
+
};
|
|
101
|
+
/** The end offset (exclusive of `\n`) of that line. */
|
|
102
|
+
const toLineEnd = (text, offset) => {
|
|
103
|
+
const nextBreak = text.indexOf("\n", offset);
|
|
104
|
+
return nextBreak === -1 ? text.length : nextBreak;
|
|
105
|
+
};
|
|
106
|
+
const toChildNamed = (node, name) => {
|
|
107
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
108
|
+
if (child.name === name) {
|
|
109
|
+
return child;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* A heading's `#` run, plus the whitespace after it.
|
|
116
|
+
*
|
|
117
|
+
* Concealing `#` alone would leave the heading text indented by the
|
|
118
|
+
* space that used to separate them — a ragged left edge that reads
|
|
119
|
+
* as a bug. The parser hands back only the hashes, so the trailing
|
|
120
|
+
* run is walked here.
|
|
121
|
+
*/
|
|
122
|
+
const toHeaderMarkEnd = (text, to) => {
|
|
123
|
+
let end = to;
|
|
124
|
+
while (end < text.length) {
|
|
125
|
+
const character = text[end];
|
|
126
|
+
if (character !== " " && character !== "\t") {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
end += 1;
|
|
130
|
+
}
|
|
131
|
+
return end;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Every decoration the document wants, in tree order.
|
|
135
|
+
*
|
|
136
|
+
* Order is not sorted here on purpose — `Decoration.set(ranges,
|
|
137
|
+
* true)` sorts, and doing it twice is the sort of thing that looks
|
|
138
|
+
* free until a document is long.
|
|
139
|
+
*/
|
|
140
|
+
export const toLivePreviewRanges = ({ from, isRawMode = false, selections, text, to, tree, }) => {
|
|
141
|
+
const ranges = [];
|
|
142
|
+
const pushMarker = (from, to, isRevealed) => {
|
|
143
|
+
if (from >= to) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
ranges.push({
|
|
147
|
+
from,
|
|
148
|
+
isConcealed: !isRawMode && !isRevealed,
|
|
149
|
+
to,
|
|
150
|
+
type: "marker",
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
tree.iterate({
|
|
154
|
+
from,
|
|
155
|
+
to,
|
|
156
|
+
enter: (nodeRef) => {
|
|
157
|
+
const { name } = nodeRef;
|
|
158
|
+
const headingKind = HEADING_LINE_KINDS[name];
|
|
159
|
+
if (headingKind) {
|
|
160
|
+
if (!isRawMode) {
|
|
161
|
+
const lineStart = toLineStart(text, nodeRef.from);
|
|
162
|
+
// Zero-length, at the line start. A line decoration is
|
|
163
|
+
// anchored to a position, not a span — give it a real
|
|
164
|
+
// range and CodeMirror throws rather than paints.
|
|
165
|
+
ranges.push({
|
|
166
|
+
from: lineStart,
|
|
167
|
+
lineKind: headingKind,
|
|
168
|
+
to: lineStart,
|
|
169
|
+
type: "line",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (name === "Blockquote" || name === "FencedCode") {
|
|
175
|
+
if (!isRawMode) {
|
|
176
|
+
// One line decoration per line: CodeMirror anchors a line
|
|
177
|
+
// decoration at a line start, so a multi-line block needs
|
|
178
|
+
// one per line rather than one spanning range.
|
|
179
|
+
for (let offset = toLineStart(text, nodeRef.from); offset < nodeRef.to; offset = toLineEnd(text, offset) + 1) {
|
|
180
|
+
ranges.push({
|
|
181
|
+
from: offset,
|
|
182
|
+
lineKind: name === "Blockquote"
|
|
183
|
+
? "blockquote"
|
|
184
|
+
: "code",
|
|
185
|
+
to: offset,
|
|
186
|
+
type: "line",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (name === "Table") {
|
|
193
|
+
if (!isRawMode) {
|
|
194
|
+
for (let offset = toLineStart(text, nodeRef.from); offset < nodeRef.to; offset = toLineEnd(text, offset) + 1) {
|
|
195
|
+
ranges.push({
|
|
196
|
+
from: offset,
|
|
197
|
+
lineKind: "table",
|
|
198
|
+
to: offset,
|
|
199
|
+
type: "line",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const inlineKind = INLINE_MARK_KINDS[name];
|
|
206
|
+
if (inlineKind) {
|
|
207
|
+
ranges.push({
|
|
208
|
+
from: nodeRef.from,
|
|
209
|
+
markKind: inlineKind,
|
|
210
|
+
to: nodeRef.to,
|
|
211
|
+
type: "mark",
|
|
212
|
+
});
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (name === "Image") {
|
|
216
|
+
const node = nodeRef.node;
|
|
217
|
+
const urlNode = toChildNamed(node, "URL");
|
|
218
|
+
const openMark = node.firstChild;
|
|
219
|
+
const closeMark = openMark?.nextSibling;
|
|
220
|
+
// An image whose caret is inside it is markup again: you
|
|
221
|
+
// cannot edit a URL you cannot see.
|
|
222
|
+
if (!isRawMode &&
|
|
223
|
+
urlNode &&
|
|
224
|
+
openMark &&
|
|
225
|
+
closeMark &&
|
|
226
|
+
!isInside(selections, node.from, node.to)) {
|
|
227
|
+
ranges.push({
|
|
228
|
+
alt: text.slice(openMark.to, closeMark.from),
|
|
229
|
+
from: node.from,
|
|
230
|
+
to: node.to,
|
|
231
|
+
type: "image",
|
|
232
|
+
url: text.slice(urlNode.from, urlNode.to),
|
|
233
|
+
});
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (name === "Link") {
|
|
239
|
+
const node = nodeRef.node;
|
|
240
|
+
const urlNode = toChildNamed(node, "URL");
|
|
241
|
+
const isRevealed = isInside(selections, node.from, node.to);
|
|
242
|
+
const openMark = node.firstChild;
|
|
243
|
+
const closeMark = openMark?.nextSibling;
|
|
244
|
+
if (urlNode && openMark && closeMark) {
|
|
245
|
+
ranges.push({
|
|
246
|
+
from: openMark.to,
|
|
247
|
+
markKind: "linkText",
|
|
248
|
+
to: closeMark.from,
|
|
249
|
+
type: "mark",
|
|
250
|
+
url: text.slice(urlNode.from, urlNode.to),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// Children are walked so nested emphasis inside link text
|
|
254
|
+
// still paints, but the marks are emitted here where the
|
|
255
|
+
// whole construct's reveal state is known.
|
|
256
|
+
pushMarker(node.from, openMark?.to ?? node.from, isRevealed);
|
|
257
|
+
if (closeMark) {
|
|
258
|
+
pushMarker(closeMark.from, node.to, isRevealed);
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (name === "URL") {
|
|
263
|
+
// A bare URL. GFM's autolink emits a `URL` with no `Link`
|
|
264
|
+
// parent, which is the whole of gap 2 in Docket's handoff:
|
|
265
|
+
// paste a link, get a link, with no `[](…)` typed at all.
|
|
266
|
+
const parentName = nodeRef.node.parent?.name;
|
|
267
|
+
if (parentName !== "Link" &&
|
|
268
|
+
parentName !== "Image") {
|
|
269
|
+
ranges.push({
|
|
270
|
+
from: nodeRef.from,
|
|
271
|
+
markKind: "autolink",
|
|
272
|
+
to: nodeRef.to,
|
|
273
|
+
type: "mark",
|
|
274
|
+
url: text.slice(nodeRef.from, nodeRef.to),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
ranges.push({
|
|
279
|
+
from: nodeRef.from,
|
|
280
|
+
markKind: "url",
|
|
281
|
+
to: nodeRef.to,
|
|
282
|
+
type: "mark",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (name === "TaskMarker") {
|
|
288
|
+
const isRevealed = isInside(selections, nodeRef.from, nodeRef.to);
|
|
289
|
+
if (!isRawMode && !isRevealed) {
|
|
290
|
+
ranges.push({
|
|
291
|
+
from: nodeRef.from,
|
|
292
|
+
isChecked: text[nodeRef.from + 1] !== " ",
|
|
293
|
+
label: text
|
|
294
|
+
.slice(nodeRef.to, toLineEnd(text, nodeRef.to))
|
|
295
|
+
.trim(),
|
|
296
|
+
to: nodeRef.to,
|
|
297
|
+
type: "task",
|
|
298
|
+
});
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
pushMarker(nodeRef.from, nodeRef.to, isRevealed);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (PERSISTENT_MARK_NAMES.has(name)) {
|
|
305
|
+
pushMarker(nodeRef.from, nodeRef.to, true);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (name === "TableDelimiter") {
|
|
309
|
+
pushMarker(nodeRef.from, nodeRef.to, true);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (name === "CodeInfo") {
|
|
313
|
+
pushMarker(nodeRef.from, nodeRef.to, true);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (CONCEALABLE_MARK_NAMES.has(name)) {
|
|
317
|
+
const parent = nodeRef.node.parent;
|
|
318
|
+
if (parent &&
|
|
319
|
+
FENCED_CODE_PARENTS.has(parent.name)) {
|
|
320
|
+
pushMarker(nodeRef.from, nodeRef.to, true);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
// A link's marks were already emitted above, with the
|
|
324
|
+
// link's own reveal state spanning both sides of the text.
|
|
325
|
+
// An image's were not — it either replaced itself outright
|
|
326
|
+
// or fell through to here, where the generic parent-range
|
|
327
|
+
// rule is already the correct one.
|
|
328
|
+
if (parent?.name === "Link") {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
const isRevealed = parent
|
|
332
|
+
? isInside(selections, parent.from, parent.to)
|
|
333
|
+
: isInside(selections, nodeRef.from, nodeRef.to);
|
|
334
|
+
const to = name === "HeaderMark"
|
|
335
|
+
? toHeaderMarkEnd(text, nodeRef.to)
|
|
336
|
+
: nodeRef.to;
|
|
337
|
+
pushMarker(nodeRef.from, to, isRevealed);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
return ranges;
|
|
344
|
+
};
|
|
345
|
+
//# sourceMappingURL=livePreviewRanges.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"livePreviewRanges.js","sourceRoot":"","sources":["../../src/markdownEditorCodeMirror/livePreviewRanges.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@charcuterie/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"default": "./dist/index.js"
|
|
21
21
|
},
|
|
22
|
+
"./markdown-editor-codemirror": {
|
|
23
|
+
"source": "./src/markdownEditorCodeMirror/index.ts",
|
|
24
|
+
"types": "./dist/markdownEditorCodeMirror/index.d.ts",
|
|
25
|
+
"default": "./dist/markdownEditorCodeMirror/index.js"
|
|
26
|
+
},
|
|
22
27
|
"./react-router": {
|
|
23
28
|
"source": "./src/reactRouter/index.ts",
|
|
24
29
|
"types": "./dist/reactRouter/index.d.ts",
|
|
@@ -55,16 +60,46 @@
|
|
|
55
60
|
"tailwind-merge": "^3.6.0"
|
|
56
61
|
},
|
|
57
62
|
"peerDependencies": {
|
|
63
|
+
"@codemirror/commands": "^6",
|
|
64
|
+
"@codemirror/lang-markdown": "^6",
|
|
65
|
+
"@codemirror/language": "^6",
|
|
66
|
+
"@codemirror/state": "^6",
|
|
67
|
+
"@codemirror/view": "^6",
|
|
68
|
+
"@lezer/common": "^1",
|
|
58
69
|
"react": ">=19",
|
|
59
70
|
"react-dom": ">=19",
|
|
60
71
|
"react-router": ">=7"
|
|
61
72
|
},
|
|
62
73
|
"peerDependenciesMeta": {
|
|
74
|
+
"@codemirror/commands": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"@codemirror/lang-markdown": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"@codemirror/language": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@codemirror/state": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@codemirror/view": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"@lezer/common": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
63
92
|
"react-router": {
|
|
64
93
|
"optional": true
|
|
65
94
|
}
|
|
66
95
|
},
|
|
67
96
|
"devDependencies": {
|
|
97
|
+
"@codemirror/commands": "^6",
|
|
98
|
+
"@codemirror/lang-markdown": "^6",
|
|
99
|
+
"@codemirror/language": "^6",
|
|
100
|
+
"@codemirror/state": "^6",
|
|
101
|
+
"@codemirror/view": "^6",
|
|
102
|
+
"@lezer/common": "^1",
|
|
68
103
|
"@storybook/react": "^10.3.6",
|
|
69
104
|
"@types/react": "^19.2.14",
|
|
70
105
|
"@types/react-dom": "^19.2.3",
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -96,6 +96,41 @@ test("the label names the control it points at", async () => {
|
|
|
96
96
|
await expectNoAxeViolations(canvasElement)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Pins a string coupling that lives in two files.
|
|
101
|
+
*
|
|
102
|
+
* `htmlFor` names every **labelable** element — `<input>`,
|
|
103
|
+
* `<textarea>`, `<select>`, `<button>` — and names a
|
|
104
|
+
* `div[role="textbox"]` not at all. So a `contenteditable` control
|
|
105
|
+
* inside a `Field` points `aria-labelledby` at this id instead, and
|
|
106
|
+
* `MarkdownEditorCodeMirror` builds it as `` `${id}-label` ``.
|
|
107
|
+
*
|
|
108
|
+
* Renaming the format here would leave that control with no
|
|
109
|
+
* accessible name — silently, because the markup still looks right
|
|
110
|
+
* and the label still renders. Only axe would notice, and only in
|
|
111
|
+
* the other component's suite.
|
|
112
|
+
*/
|
|
113
|
+
test("the label carries a derivable id for non-labelable controls", async () => {
|
|
114
|
+
const { canvas } = await mountStory(Default)
|
|
115
|
+
|
|
116
|
+
const control = expectAgentDrivable(canvas, {
|
|
117
|
+
name: "Output directory",
|
|
118
|
+
role: "textbox",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const controlId = control.getAttribute("id")
|
|
122
|
+
|
|
123
|
+
await expect(controlId).not.toBeNull()
|
|
124
|
+
|
|
125
|
+
const label = document.getElementById(
|
|
126
|
+
`${controlId}-label`,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
await expect(label).not.toBeNull()
|
|
130
|
+
|
|
131
|
+
await expect(label).toHaveAttribute("for", controlId)
|
|
132
|
+
})
|
|
133
|
+
|
|
99
134
|
test("a description is announced with the control", async () => {
|
|
100
135
|
const { canvas } = await mountStory(Default)
|
|
101
136
|
|
package/src/Field/Field.tsx
CHANGED
|
@@ -183,6 +183,24 @@ export const Field = ({
|
|
|
183
183
|
<label
|
|
184
184
|
className="font-medium text-content-primary text-sm"
|
|
185
185
|
htmlFor={controlId}
|
|
186
|
+
/**
|
|
187
|
+
* `htmlFor` names the control for every **labelable**
|
|
188
|
+
* element — `<input>`, `<textarea>`, `<select>`, `<button>`
|
|
189
|
+
* — which is all this component wrapped until a
|
|
190
|
+
* `contenteditable` arrived. A `div[role="textbox"]` is not
|
|
191
|
+
* in that list, so `htmlFor` alone leaves it with **no
|
|
192
|
+
* accessible name at all**, silently: the markup looks
|
|
193
|
+
* right, the label renders, and axe is the only thing that
|
|
194
|
+
* notices.
|
|
195
|
+
*
|
|
196
|
+
* So the label also carries a derivable id, and such a
|
|
197
|
+
* control points `aria-labelledby` at it.
|
|
198
|
+
* `MarkdownEditorCodeMirror` is the first consumer;
|
|
199
|
+
* `Field.test.tsx` pins the format, because the coupling is
|
|
200
|
+
* a string convention and a rename here would break a name
|
|
201
|
+
* over in another file.
|
|
202
|
+
*/
|
|
203
|
+
id={`${controlId}-label`}
|
|
186
204
|
>
|
|
187
205
|
{label}
|
|
188
206
|
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
indentLines,
|
|
19
19
|
insertLink,
|
|
20
20
|
insertText,
|
|
21
|
+
isLinkPaste,
|
|
21
22
|
MARKDOWN_LINE_PREFIXES,
|
|
22
23
|
outdentLines,
|
|
23
24
|
toggleHeading,
|
|
24
25
|
toggleInlineMarker,
|
|
25
26
|
toggleLinePrefix,
|
|
26
27
|
toMarkdownImage,
|
|
28
|
+
wrapSelectionInLink,
|
|
27
29
|
} from "./markdownCommands.ts"
|
|
28
30
|
import type { MarkdownSpanKind } from "./markdownSpans.ts"
|
|
29
31
|
import {
|
|
@@ -464,17 +466,41 @@ export const MarkdownEditor = ({
|
|
|
464
466
|
pasteEvent.clipboardData.files,
|
|
465
467
|
).filter(isImageFile)
|
|
466
468
|
|
|
467
|
-
if (files.length
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
if (files.length > 0 && onUploadImage) {
|
|
470
|
+
pasteEvent.preventDefault()
|
|
471
|
+
|
|
472
|
+
uploadImages(files)
|
|
473
|
+
|
|
472
474
|
return
|
|
473
475
|
}
|
|
474
476
|
|
|
475
|
-
|
|
477
|
+
const pasted =
|
|
478
|
+
pasteEvent.clipboardData.getData("text/plain")
|
|
476
479
|
|
|
477
|
-
|
|
480
|
+
const selection = readSelection()
|
|
481
|
+
|
|
482
|
+
// A URL pasted over selected text becomes a link, the way it
|
|
483
|
+
// does in every editor anyone uses. Reading `text/plain` here
|
|
484
|
+
// is not a hole in the no-HTML guarantee — that flavour is the
|
|
485
|
+
// only one a `<textarea>` would have taken anyway, and what
|
|
486
|
+
// goes back in is markdown built by a command.
|
|
487
|
+
if (
|
|
488
|
+
isLinkPaste(pasted) &&
|
|
489
|
+
selection.selectionStart !== selection.selectionEnd
|
|
490
|
+
) {
|
|
491
|
+
pasteEvent.preventDefault()
|
|
492
|
+
|
|
493
|
+
applyEdit(
|
|
494
|
+
wrapSelectionInLink(selection, pasted.trim()),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Everything else is left to the browser on purpose. A
|
|
501
|
+
// `<textarea>` pastes `text/plain` and nothing else, so the
|
|
502
|
+
// no-HTML guarantee needs no code here — intercepting the paste
|
|
503
|
+
// to "sanitise" it would be the version that can have a bug.
|
|
478
504
|
}
|
|
479
505
|
|
|
480
506
|
const handleDrop = (
|
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
insertImage,
|
|
8
8
|
insertLink,
|
|
9
9
|
insertText,
|
|
10
|
+
isLinkPaste,
|
|
10
11
|
MARKDOWN_LINE_PREFIXES,
|
|
11
12
|
outdentLines,
|
|
12
13
|
toggleHeading,
|
|
13
14
|
toggleInlineMarker,
|
|
14
15
|
toggleLinePrefix,
|
|
15
16
|
toMarkdownImage,
|
|
17
|
+
wrapSelectionInLink,
|
|
16
18
|
} from "./markdownCommands.ts"
|
|
17
19
|
|
|
18
20
|
const at = (text: string, marker = "|") => {
|
|
@@ -382,3 +384,56 @@ test("the minimal edit is the smallest range that explains the change", () => {
|
|
|
382
384
|
text: "",
|
|
383
385
|
})
|
|
384
386
|
})
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pasting a URL over selected text is what every editor the owner
|
|
390
|
+
* uses already does, and its absence is why a link pasted into a
|
|
391
|
+
* Docket description stayed flat text.
|
|
392
|
+
*/
|
|
393
|
+
test("a URL pasted over a selection wraps it", () => {
|
|
394
|
+
expect(
|
|
395
|
+
wrapSelectionInLink(
|
|
396
|
+
at("see |the docs| now"),
|
|
397
|
+
"https://example.com",
|
|
398
|
+
),
|
|
399
|
+
).toEqual({
|
|
400
|
+
// After the whole insertion, not inside the destination: the
|
|
401
|
+
// URL is already known, so parking the caret in it would ask
|
|
402
|
+
// the user to replace what they just pasted.
|
|
403
|
+
selectionEnd: 35,
|
|
404
|
+
selectionStart: 35,
|
|
405
|
+
text: "see [the docs](https://example.com) now",
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test("a URL pasted with no selection is inserted plainly", () => {
|
|
410
|
+
// Autolink renders a bare URL as a link anyway, so wrapping it
|
|
411
|
+
// with no text would produce `[](url)` — an empty link.
|
|
412
|
+
expect(
|
|
413
|
+
wrapSelectionInLink(
|
|
414
|
+
at("see | now"),
|
|
415
|
+
"https://example.com",
|
|
416
|
+
),
|
|
417
|
+
).toEqual({
|
|
418
|
+
selectionEnd: 23,
|
|
419
|
+
selectionStart: 23,
|
|
420
|
+
text: "see https://example.com now",
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test.each([
|
|
425
|
+
["https://example.com", true],
|
|
426
|
+
["http://example.com/a?b=c#d", true],
|
|
427
|
+
[" https://example.com ", true],
|
|
428
|
+
["not a url", false],
|
|
429
|
+
["", false],
|
|
430
|
+
// Deliberately strict: a paste that is only *mostly* a URL is
|
|
431
|
+
// left alone, because mangling a literal paste is worse than
|
|
432
|
+
// not linkifying one.
|
|
433
|
+
["see https://example.com", false],
|
|
434
|
+
["https://a.com https://b.com", false],
|
|
435
|
+
["mailto:someone@example.com", false],
|
|
436
|
+
["www.example.com", false],
|
|
437
|
+
])("isLinkPaste(%j) is %s", (pasted, isExpected) => {
|
|
438
|
+
expect(isLinkPaste(pasted)).toBe(isExpected)
|
|
439
|
+
})
|