@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.
Files changed (45) hide show
  1. package/dist/Field/Field.d.ts.map +1 -1
  2. package/dist/Field/Field.js +19 -1
  3. package/dist/Field/Field.js.map +1 -1
  4. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  6. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  7. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  8. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  9. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  10. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  11. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  13. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  14. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  15. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  17. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  18. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  19. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  20. package/dist/markdownEditorCodeMirror/index.js +14 -0
  21. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  22. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  23. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  24. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  25. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  26. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  28. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  29. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  30. package/package.json +36 -1
  31. package/src/Field/Field.test.tsx +35 -0
  32. package/src/Field/Field.tsx +18 -0
  33. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  34. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  35. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  36. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  37. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  38. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  39. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  40. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  41. package/src/markdownEditorCodeMirror/index.ts +26 -0
  42. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  43. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  44. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  45. 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 `![alt](url)` 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.0",
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",
@@ -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
 
@@ -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 === 0 || !onUploadImage) {
468
- // Left to the browser on purpose. A `<textarea>` pastes
469
- // `text/plain` and nothing else, so the no-HTML guarantee
470
- // needs no code here at all — intercepting the paste to
471
- // "sanitise" it would be the version that can have a bug.
469
+ if (files.length > 0 && onUploadImage) {
470
+ pasteEvent.preventDefault()
471
+
472
+ uploadImages(files)
473
+
472
474
  return
473
475
  }
474
476
 
475
- pasteEvent.preventDefault()
477
+ const pasted =
478
+ pasteEvent.clipboardData.getData("text/plain")
476
479
 
477
- uploadImages(files)
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
+ })