@charcuterie/ui 3.0.0 → 3.2.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/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- package/src/sourceRules.test.ts +52 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
toInlineSpans,
|
|
5
|
+
toLineIndex,
|
|
6
|
+
toMarkdownLines,
|
|
7
|
+
} from "./markdownSpans.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The corpus every invariant below runs over. Deliberately nasty:
|
|
11
|
+
* the constructs the editor colours, the ones it does not, and the
|
|
12
|
+
* characters that tempt an HTML-escaping implementation.
|
|
13
|
+
*/
|
|
14
|
+
const CORPUS = [
|
|
15
|
+
"",
|
|
16
|
+
"\n",
|
|
17
|
+
"plain text",
|
|
18
|
+
"# Heading one",
|
|
19
|
+
"###### Heading six with **bold** and `code`",
|
|
20
|
+
"#no-space-is-not-a-heading",
|
|
21
|
+
" ## indented heading",
|
|
22
|
+
"> quoted line",
|
|
23
|
+
">> doubly quoted",
|
|
24
|
+
"> ## a heading inside a quote",
|
|
25
|
+
"- bullet",
|
|
26
|
+
"* star bullet",
|
|
27
|
+
"+ plus bullet",
|
|
28
|
+
"1. ordered",
|
|
29
|
+
"12) ordered with a paren",
|
|
30
|
+
"- [ ] unchecked task",
|
|
31
|
+
"- [x] checked task",
|
|
32
|
+
" - nested bullet",
|
|
33
|
+
"---",
|
|
34
|
+
"***",
|
|
35
|
+
"| a | b |",
|
|
36
|
+
"| --- | --- |",
|
|
37
|
+
"| `code` | **bold** |",
|
|
38
|
+
"text with **bold** and _em_ and ~~struck~~ and `code`",
|
|
39
|
+
"an  inline",
|
|
40
|
+
"a [link](https://example.test/page) inline",
|
|
41
|
+
"escaped \\*not emphasis\\* here",
|
|
42
|
+
"`**not bold inside code**`",
|
|
43
|
+
"unterminated **bold",
|
|
44
|
+
"unterminated `code",
|
|
45
|
+
"```ts",
|
|
46
|
+
"const x = '**not bold**'",
|
|
47
|
+
"```",
|
|
48
|
+
"5 * 3 * 2 is arithmetic",
|
|
49
|
+
"snake_case_identifier stays plain",
|
|
50
|
+
"<script>alert('xss')</script>",
|
|
51
|
+
"a < b && c > d",
|
|
52
|
+
"& < ' "",
|
|
53
|
+
"1 < 2 > 3 <b>bold?</b>",
|
|
54
|
+
"trailing spaces ",
|
|
55
|
+
"\ttab indented",
|
|
56
|
+
].join("\n")
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* **The invariant the whole component rests on.**
|
|
60
|
+
*
|
|
61
|
+
* The painted layer sits behind a real `<textarea>` in one grid
|
|
62
|
+
* cell, and the two only stay registered while they contain the
|
|
63
|
+
* same characters in the same order. Drop a trailing space,
|
|
64
|
+
* normalise a tab, swallow an unterminated marker — any of them
|
|
65
|
+
* shears the caret away from the glyphs, by a character per
|
|
66
|
+
* offence, cumulative down the line. Nothing else would catch it:
|
|
67
|
+
* the colours look right, axe is silent, and it only shows up as a
|
|
68
|
+
* cursor that is slightly in the wrong place.
|
|
69
|
+
*/
|
|
70
|
+
test("every character survives tokenizing", () => {
|
|
71
|
+
const rebuilt = toMarkdownLines(CORPUS)
|
|
72
|
+
.map((line) =>
|
|
73
|
+
line.spans.map((span) => span.text).join(""),
|
|
74
|
+
)
|
|
75
|
+
.join("\n")
|
|
76
|
+
|
|
77
|
+
expect(rebuilt).toBe(CORPUS)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("the line count matches the source exactly", () => {
|
|
81
|
+
expect(toMarkdownLines(CORPUS)).toHaveLength(
|
|
82
|
+
CORPUS.split("\n").length,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// An empty document still has one line, because the caret has to
|
|
86
|
+
// sit somewhere and the painted layer needs a row to agree about.
|
|
87
|
+
expect(toMarkdownLines("")).toHaveLength(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The no-HTML guarantee, at the layer where an implementation is
|
|
92
|
+
* most tempted to break it. Nothing here builds a string for
|
|
93
|
+
* `innerHTML`, so a tag in the source has to come back out as
|
|
94
|
+
* *text* — not as `<script>`, and not as markup.
|
|
95
|
+
*/
|
|
96
|
+
test("HTML in the source stays literal text", () => {
|
|
97
|
+
const source =
|
|
98
|
+
"<script>alert('x')</script> & <b>bold</b> \"quoted\" 'single'"
|
|
99
|
+
|
|
100
|
+
const spans = toMarkdownLines(source).flatMap(
|
|
101
|
+
(line) => line.spans,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const rebuilt = spans.map((span) => span.text).join("")
|
|
105
|
+
|
|
106
|
+
expect(rebuilt).toBe(source)
|
|
107
|
+
|
|
108
|
+
expect(rebuilt).not.toContain("<")
|
|
109
|
+
|
|
110
|
+
expect(rebuilt).not.toContain("&")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("headings, lists, quotes and tables are told apart", () => {
|
|
114
|
+
const kinds = toMarkdownLines(
|
|
115
|
+
[
|
|
116
|
+
"# Heading",
|
|
117
|
+
"- bullet",
|
|
118
|
+
"> quote",
|
|
119
|
+
"| a |",
|
|
120
|
+
"---",
|
|
121
|
+
"plain",
|
|
122
|
+
"```",
|
|
123
|
+
"fenced",
|
|
124
|
+
"```",
|
|
125
|
+
].join("\n"),
|
|
126
|
+
).map((line) => line.kind)
|
|
127
|
+
|
|
128
|
+
expect(kinds).toEqual([
|
|
129
|
+
"heading",
|
|
130
|
+
"list",
|
|
131
|
+
"blockquote",
|
|
132
|
+
"table",
|
|
133
|
+
"thematicBreak",
|
|
134
|
+
"paragraph",
|
|
135
|
+
"code",
|
|
136
|
+
"code",
|
|
137
|
+
"code",
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("a fence suppresses every construct inside it", () => {
|
|
142
|
+
const [, inside] = toMarkdownLines(
|
|
143
|
+
"```\n**not bold** and [not a link](x)\n```",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
expect(inside?.spans).toEqual([
|
|
147
|
+
{
|
|
148
|
+
kind: "code",
|
|
149
|
+
text: "**not bold** and [not a link](x)",
|
|
150
|
+
},
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("a code span suppresses emphasis inside it", () => {
|
|
155
|
+
expect(toInlineSpans("`**a**`")).toEqual([
|
|
156
|
+
{ kind: "marker", text: "`" },
|
|
157
|
+
{ kind: "code", text: "**a**" },
|
|
158
|
+
{ kind: "marker", text: "`" },
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("an escape wins over the character it escapes", () => {
|
|
163
|
+
expect(toInlineSpans("\\*a\\*")).toEqual([
|
|
164
|
+
{ kind: "marker", text: "\\" },
|
|
165
|
+
{ kind: "plain", text: "*a" },
|
|
166
|
+
{ kind: "marker", text: "\\" },
|
|
167
|
+
{ kind: "plain", text: "*" },
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("strong beats emphasis, because `**` starts with `*`", () => {
|
|
172
|
+
expect(toInlineSpans("**a**")).toEqual([
|
|
173
|
+
{ kind: "marker", text: "**" },
|
|
174
|
+
{ kind: "strong", text: "a" },
|
|
175
|
+
{ kind: "marker", text: "**" },
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("an image is told apart from the link inside it", () => {
|
|
180
|
+
expect(toInlineSpans("")).toEqual([
|
|
181
|
+
{ kind: "marker", text: "" },
|
|
186
|
+
])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Adjacent spans of one kind are merged as they are pushed. Without
|
|
191
|
+
* it a 200-character paragraph is 200 React elements and every
|
|
192
|
+
* keystroke reconciles all of them.
|
|
193
|
+
*/
|
|
194
|
+
test("plain runs are one span, not one per character", () => {
|
|
195
|
+
expect(toInlineSpans("hello world")).toEqual([
|
|
196
|
+
{ kind: "plain", text: "hello world" },
|
|
197
|
+
])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("the caret's line is found from its offset", () => {
|
|
201
|
+
const text = "one\ntwo\nthree"
|
|
202
|
+
|
|
203
|
+
expect(toLineIndex(text, 0)).toBe(0)
|
|
204
|
+
|
|
205
|
+
expect(toLineIndex(text, 3)).toBe(0)
|
|
206
|
+
|
|
207
|
+
expect(toLineIndex(text, 4)).toBe(1)
|
|
208
|
+
|
|
209
|
+
expect(toLineIndex(text, text.length)).toBe(2)
|
|
210
|
+
|
|
211
|
+
// Out of range on both ends rather than throwing: the caret is
|
|
212
|
+
// read from a live control and a stale offset must not crash a
|
|
213
|
+
// render.
|
|
214
|
+
expect(toLineIndex(text, -10)).toBe(0)
|
|
215
|
+
|
|
216
|
+
expect(toLineIndex(text, 9999)).toBe(2)
|
|
217
|
+
})
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → coloured spans, with **every character kept**.
|
|
3
|
+
*
|
|
4
|
+
* This is the half of the live-hybrid editor that decides what a
|
|
5
|
+
* line looks like, and its one hard requirement is not a parsing
|
|
6
|
+
* requirement at all — it is a geometry one.
|
|
7
|
+
*
|
|
8
|
+
* ### The concatenation invariant
|
|
9
|
+
*
|
|
10
|
+
* The editing surface is a real `<textarea>` with a painted layer
|
|
11
|
+
* behind it, sharing one grid cell. The textarea owns the caret,
|
|
12
|
+
* the selection, the undo stack and the IME; the layer owns the
|
|
13
|
+
* colour. That only works while the two layers **wrap identically**,
|
|
14
|
+
* and they only wrap identically while they contain the *same
|
|
15
|
+
* characters in the same order*.
|
|
16
|
+
*
|
|
17
|
+
* So `toMarkdownLines(text)` is not a parser in the usual sense. It
|
|
18
|
+
* never drops a marker, never normalises whitespace, never resolves
|
|
19
|
+
* a reference, and never emits a node the source did not contain.
|
|
20
|
+
* Joining every span's text back together, with `\n` between lines,
|
|
21
|
+
* reproduces the input byte for byte — `markdownSpans.test.ts`
|
|
22
|
+
* asserts exactly that over a fixture corpus, and it is the test
|
|
23
|
+
* that stops a well-meaning "let's trim the trailing space"
|
|
24
|
+
* from silently shearing the caret away from the glyphs.
|
|
25
|
+
*
|
|
26
|
+
* ### It emits kinds, not HTML
|
|
27
|
+
*
|
|
28
|
+
* The return value is a list of `{ kind, text }` pairs. There is no
|
|
29
|
+
* HTML string anywhere in this module, no `innerHTML`, and no
|
|
30
|
+
* sanitiser — because there is nothing to sanitise. Docket's whole
|
|
31
|
+
* reason to exist is that HTML-in-the-field destroyed a previous
|
|
32
|
+
* tool's data, and the cheapest possible guarantee against that is
|
|
33
|
+
* a pipeline in which HTML is never *constructed*. React renders
|
|
34
|
+
* these spans as text nodes; a `<script>` in the source is a
|
|
35
|
+
* `<script>` on screen.
|
|
36
|
+
*
|
|
37
|
+
* ### Why it is line-based, and what that costs
|
|
38
|
+
*
|
|
39
|
+
* A CommonMark parser resolves lazy continuation, setext headings,
|
|
40
|
+
* link reference definitions and nested block containers across the
|
|
41
|
+
* whole document. This resolves one line at a time, carrying
|
|
42
|
+
* exactly one bit of cross-line state: whether we are inside a
|
|
43
|
+
* fenced code block.
|
|
44
|
+
*
|
|
45
|
+
* That is a deliberate trade. Painting is per-line and re-runs on
|
|
46
|
+
* every keystroke, so the cost has to be linear in the line, not in
|
|
47
|
+
* the document; and a mis-coloured marker is a cosmetic defect
|
|
48
|
+
* while a mis-*placed* one is a broken editor. The known gaps are
|
|
49
|
+
* written down in `MarkdownEditor.mdx` rather than hidden: setext
|
|
50
|
+
* headings, indented (four-space) code blocks, reference links, and
|
|
51
|
+
* emphasis that spans a line break all colour as plain text. They
|
|
52
|
+
* still **store** correctly, because the stored value is the
|
|
53
|
+
* textarea's, not this module's.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export type MarkdownSpanKind =
|
|
57
|
+
| "code"
|
|
58
|
+
| "emphasis"
|
|
59
|
+
| "heading"
|
|
60
|
+
| "link"
|
|
61
|
+
| "marker"
|
|
62
|
+
| "plain"
|
|
63
|
+
| "strikethrough"
|
|
64
|
+
| "strong"
|
|
65
|
+
| "url"
|
|
66
|
+
|
|
67
|
+
export type MarkdownSpan = {
|
|
68
|
+
kind: MarkdownSpanKind
|
|
69
|
+
text: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MarkdownLineKind =
|
|
73
|
+
| "blockquote"
|
|
74
|
+
| "code"
|
|
75
|
+
| "heading"
|
|
76
|
+
| "list"
|
|
77
|
+
| "paragraph"
|
|
78
|
+
| "table"
|
|
79
|
+
| "thematicBreak"
|
|
80
|
+
|
|
81
|
+
export type MarkdownLine = {
|
|
82
|
+
kind: MarkdownLineKind
|
|
83
|
+
spans: MarkdownSpan[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A fence opens or closes on any line whose first non-space run is
|
|
88
|
+
* three or more backticks or tildes. Up to three leading spaces,
|
|
89
|
+
* per CommonMark; a fourth would be an indented code block, which
|
|
90
|
+
* this module deliberately does not track.
|
|
91
|
+
*/
|
|
92
|
+
const FENCE_PATTERN = /^ {0,3}(?:```|~~~)/
|
|
93
|
+
|
|
94
|
+
const INDENT_PATTERN = /^[ \t]*/
|
|
95
|
+
|
|
96
|
+
const BLOCKQUOTE_PATTERN = /^(?:>[ \t]?)+/
|
|
97
|
+
|
|
98
|
+
const THEMATIC_BREAK_PATTERN =
|
|
99
|
+
/^(?:\*[ \t]*){3,}$|^(?:-[ \t]*){3,}$|^(?:_[ \t]*){3,}$/
|
|
100
|
+
|
|
101
|
+
const HEADING_PATTERN = /^#{1,6}[ \t]+/
|
|
102
|
+
|
|
103
|
+
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d{1,9}[.)])[ \t]+/
|
|
104
|
+
|
|
105
|
+
const TASK_MARKER_PATTERN = /^\[[ xX]\][ \t]*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sticky, because every one of these is tried at a known index
|
|
109
|
+
* rather than searched for. A non-sticky `exec` would happily match
|
|
110
|
+
* an emphasis pair fifty characters further down the line and the
|
|
111
|
+
* scanner would swallow everything in between.
|
|
112
|
+
*
|
|
113
|
+
* Order is the whole grammar here, and two orderings are
|
|
114
|
+
* load-bearing:
|
|
115
|
+
*
|
|
116
|
+
* - **Escape first.** `\*not emphasis\*` has to lose to the
|
|
117
|
+
* backslash, or the editor colours text the renderer will not.
|
|
118
|
+
* - **Code before everything.** A code span suppresses every other
|
|
119
|
+
* construct inside it, which is what makes `` `**` `` render as
|
|
120
|
+
* two asterisks rather than an unterminated bold run.
|
|
121
|
+
* - **Image before link**, because `` starts with a
|
|
122
|
+
* valid link at index 1.
|
|
123
|
+
* - **Strong before emphasis**, because `**` starts with `*`.
|
|
124
|
+
*/
|
|
125
|
+
const INLINE_PATTERNS = {
|
|
126
|
+
code: /(`+)([^\n]*?)\1/y,
|
|
127
|
+
emphasis: /([*_])(?=[^\s*_])([^\n*_]*?[^\s*_])\1/y,
|
|
128
|
+
escape: /\\([\\`*_{}[\]()#+\-.!>~|])/y,
|
|
129
|
+
image: /(!\[)([^\]\n]*)(\]\()([^)\s\n]*)(\))/y,
|
|
130
|
+
link: /(\[)([^\]\n]*)(\]\()([^)\s\n]*)(\))/y,
|
|
131
|
+
strikethrough: /(~~)(?=\S)([^\n]*?\S)\1/y,
|
|
132
|
+
strong: /(\*\*|__)(?=\S)([^\n]*?\S)\1/y,
|
|
133
|
+
} as const
|
|
134
|
+
|
|
135
|
+
const pushSpan = (
|
|
136
|
+
spans: MarkdownSpan[],
|
|
137
|
+
kind: MarkdownSpanKind,
|
|
138
|
+
text: string,
|
|
139
|
+
) => {
|
|
140
|
+
if (text === "") {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const previous = spans.at(-1)
|
|
145
|
+
|
|
146
|
+
// Merged as they are pushed rather than in a second pass. The
|
|
147
|
+
// scanner emits one span per plain *character*, so without this a
|
|
148
|
+
// 200-character paragraph is 200 React elements and every
|
|
149
|
+
// keystroke reconciles all of them.
|
|
150
|
+
if (previous?.kind === kind) {
|
|
151
|
+
previous.text += text
|
|
152
|
+
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
spans.push({ kind, text })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The inline scanner.
|
|
161
|
+
*
|
|
162
|
+
* `baseKind` is what unmarked text becomes — `"plain"` in a
|
|
163
|
+
* paragraph, `"heading"` inside a heading — so a heading's words
|
|
164
|
+
* carry the heading colour while the `##` in front of them stays a
|
|
165
|
+
* marker.
|
|
166
|
+
*/
|
|
167
|
+
export const toInlineSpans = (
|
|
168
|
+
text: string,
|
|
169
|
+
baseKind: MarkdownSpanKind = "plain",
|
|
170
|
+
): MarkdownSpan[] => {
|
|
171
|
+
const spans: MarkdownSpan[] = []
|
|
172
|
+
|
|
173
|
+
let index = 0
|
|
174
|
+
|
|
175
|
+
while (index < text.length) {
|
|
176
|
+
INLINE_PATTERNS.escape.lastIndex = index
|
|
177
|
+
|
|
178
|
+
const escaped = INLINE_PATTERNS.escape.exec(text)
|
|
179
|
+
|
|
180
|
+
if (escaped) {
|
|
181
|
+
pushSpan(spans, "marker", "\\")
|
|
182
|
+
|
|
183
|
+
pushSpan(spans, baseKind, escaped[1] ?? "")
|
|
184
|
+
|
|
185
|
+
index += escaped[0].length
|
|
186
|
+
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
INLINE_PATTERNS.code.lastIndex = index
|
|
191
|
+
|
|
192
|
+
const code = INLINE_PATTERNS.code.exec(text)
|
|
193
|
+
|
|
194
|
+
if (code) {
|
|
195
|
+
pushSpan(spans, "marker", code[1] ?? "")
|
|
196
|
+
|
|
197
|
+
pushSpan(spans, "code", code[2] ?? "")
|
|
198
|
+
|
|
199
|
+
pushSpan(spans, "marker", code[1] ?? "")
|
|
200
|
+
|
|
201
|
+
index += code[0].length
|
|
202
|
+
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
INLINE_PATTERNS.image.lastIndex = index
|
|
207
|
+
|
|
208
|
+
const image = INLINE_PATTERNS.image.exec(text)
|
|
209
|
+
|
|
210
|
+
if (image) {
|
|
211
|
+
pushSpan(spans, "marker", image[1] ?? "")
|
|
212
|
+
|
|
213
|
+
pushSpan(spans, "link", image[2] ?? "")
|
|
214
|
+
|
|
215
|
+
pushSpan(spans, "marker", image[3] ?? "")
|
|
216
|
+
|
|
217
|
+
pushSpan(spans, "url", image[4] ?? "")
|
|
218
|
+
|
|
219
|
+
pushSpan(spans, "marker", image[5] ?? "")
|
|
220
|
+
|
|
221
|
+
index += image[0].length
|
|
222
|
+
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
INLINE_PATTERNS.link.lastIndex = index
|
|
227
|
+
|
|
228
|
+
const link = INLINE_PATTERNS.link.exec(text)
|
|
229
|
+
|
|
230
|
+
if (link) {
|
|
231
|
+
pushSpan(spans, "marker", link[1] ?? "")
|
|
232
|
+
|
|
233
|
+
pushSpan(spans, "link", link[2] ?? "")
|
|
234
|
+
|
|
235
|
+
pushSpan(spans, "marker", link[3] ?? "")
|
|
236
|
+
|
|
237
|
+
pushSpan(spans, "url", link[4] ?? "")
|
|
238
|
+
|
|
239
|
+
pushSpan(spans, "marker", link[5] ?? "")
|
|
240
|
+
|
|
241
|
+
index += link[0].length
|
|
242
|
+
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
INLINE_PATTERNS.strong.lastIndex = index
|
|
247
|
+
|
|
248
|
+
const strong = INLINE_PATTERNS.strong.exec(text)
|
|
249
|
+
|
|
250
|
+
if (strong) {
|
|
251
|
+
pushSpan(spans, "marker", strong[1] ?? "")
|
|
252
|
+
|
|
253
|
+
pushSpan(spans, "strong", strong[2] ?? "")
|
|
254
|
+
|
|
255
|
+
pushSpan(spans, "marker", strong[1] ?? "")
|
|
256
|
+
|
|
257
|
+
index += strong[0].length
|
|
258
|
+
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
INLINE_PATTERNS.strikethrough.lastIndex = index
|
|
263
|
+
|
|
264
|
+
const struck = INLINE_PATTERNS.strikethrough.exec(text)
|
|
265
|
+
|
|
266
|
+
if (struck) {
|
|
267
|
+
pushSpan(spans, "marker", struck[1] ?? "")
|
|
268
|
+
|
|
269
|
+
pushSpan(spans, "strikethrough", struck[2] ?? "")
|
|
270
|
+
|
|
271
|
+
pushSpan(spans, "marker", struck[1] ?? "")
|
|
272
|
+
|
|
273
|
+
index += struck[0].length
|
|
274
|
+
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
INLINE_PATTERNS.emphasis.lastIndex = index
|
|
279
|
+
|
|
280
|
+
const emphasised = INLINE_PATTERNS.emphasis.exec(text)
|
|
281
|
+
|
|
282
|
+
if (emphasised) {
|
|
283
|
+
pushSpan(spans, "marker", emphasised[1] ?? "")
|
|
284
|
+
|
|
285
|
+
pushSpan(spans, "emphasis", emphasised[2] ?? "")
|
|
286
|
+
|
|
287
|
+
pushSpan(spans, "marker", emphasised[1] ?? "")
|
|
288
|
+
|
|
289
|
+
index += emphasised[0].length
|
|
290
|
+
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
pushSpan(spans, baseKind, text[index] ?? "")
|
|
295
|
+
|
|
296
|
+
index += 1
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return spans
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const toTableSpans = (text: string): MarkdownSpan[] => {
|
|
303
|
+
const spans: MarkdownSpan[] = []
|
|
304
|
+
|
|
305
|
+
for (const part of text.split(/(\|)/)) {
|
|
306
|
+
if (part === "|") {
|
|
307
|
+
pushSpan(spans, "marker", part)
|
|
308
|
+
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const span of toInlineSpans(part)) {
|
|
313
|
+
pushSpan(spans, span.kind, span.text)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return spans
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const toBlockLine = (line: string): MarkdownLine => {
|
|
321
|
+
const spans: MarkdownSpan[] = []
|
|
322
|
+
|
|
323
|
+
const indent = INDENT_PATTERN.exec(line)?.[0] ?? ""
|
|
324
|
+
|
|
325
|
+
pushSpan(spans, "plain", indent)
|
|
326
|
+
|
|
327
|
+
let rest = line.slice(indent.length)
|
|
328
|
+
|
|
329
|
+
const quote = BLOCKQUOTE_PATTERN.exec(rest)?.[0]
|
|
330
|
+
|
|
331
|
+
if (quote !== undefined) {
|
|
332
|
+
pushSpan(spans, "marker", quote)
|
|
333
|
+
|
|
334
|
+
rest = rest.slice(quote.length)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (THEMATIC_BREAK_PATTERN.test(rest)) {
|
|
338
|
+
pushSpan(spans, "marker", rest)
|
|
339
|
+
|
|
340
|
+
return { kind: "thematicBreak", spans }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const heading = HEADING_PATTERN.exec(rest)?.[0]
|
|
344
|
+
|
|
345
|
+
if (heading !== undefined) {
|
|
346
|
+
pushSpan(spans, "marker", heading)
|
|
347
|
+
|
|
348
|
+
for (const span of toInlineSpans(
|
|
349
|
+
rest.slice(heading.length),
|
|
350
|
+
"heading",
|
|
351
|
+
)) {
|
|
352
|
+
pushSpan(spans, span.kind, span.text)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { kind: "heading", spans }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const listMarker = LIST_MARKER_PATTERN.exec(rest)?.[0]
|
|
359
|
+
|
|
360
|
+
if (listMarker !== undefined) {
|
|
361
|
+
pushSpan(spans, "marker", listMarker)
|
|
362
|
+
|
|
363
|
+
rest = rest.slice(listMarker.length)
|
|
364
|
+
|
|
365
|
+
const taskMarker = TASK_MARKER_PATTERN.exec(rest)?.[0]
|
|
366
|
+
|
|
367
|
+
if (taskMarker !== undefined) {
|
|
368
|
+
pushSpan(spans, "marker", taskMarker)
|
|
369
|
+
|
|
370
|
+
rest = rest.slice(taskMarker.length)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const span of toInlineSpans(rest)) {
|
|
374
|
+
pushSpan(spans, span.kind, span.text)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return { kind: "list", spans }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (rest.startsWith("|")) {
|
|
381
|
+
for (const span of toTableSpans(rest)) {
|
|
382
|
+
pushSpan(spans, span.kind, span.text)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { kind: "table", spans }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const span of toInlineSpans(rest)) {
|
|
389
|
+
pushSpan(spans, span.kind, span.text)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
kind: quote === undefined ? "paragraph" : "blockquote",
|
|
394
|
+
spans,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The whole document, one entry per line.
|
|
400
|
+
*
|
|
401
|
+
* `text.split("\n")` on an empty string yields `[""]` — one empty
|
|
402
|
+
* line — which is right: an empty editor still has a line for the
|
|
403
|
+
* caret to sit on, and the painted layer still needs a row so the
|
|
404
|
+
* two layers agree about where line 0 is.
|
|
405
|
+
*/
|
|
406
|
+
export const toMarkdownLines = (
|
|
407
|
+
text: string,
|
|
408
|
+
): MarkdownLine[] => {
|
|
409
|
+
let isInsideFence = false
|
|
410
|
+
|
|
411
|
+
return text.split("\n").map((line) => {
|
|
412
|
+
if (FENCE_PATTERN.test(line)) {
|
|
413
|
+
isInsideFence = !isInsideFence
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
kind: "code" as const,
|
|
417
|
+
spans: [{ kind: "marker" as const, text: line }],
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (isInsideFence) {
|
|
422
|
+
return {
|
|
423
|
+
kind: "code" as const,
|
|
424
|
+
spans:
|
|
425
|
+
line === ""
|
|
426
|
+
? []
|
|
427
|
+
: [{ kind: "code" as const, text: line }],
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return toBlockLine(line)
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Which line the caret is on, from a character offset.
|
|
437
|
+
*
|
|
438
|
+
* The active line is the one construct in this editor that is
|
|
439
|
+
* *state* rather than syntax: its markers paint at full strength
|
|
440
|
+
* and every other line's fade back, which is the whole "raw syntax
|
|
441
|
+
* on the cursor's line" behaviour.
|
|
442
|
+
*/
|
|
443
|
+
export const toLineIndex = (
|
|
444
|
+
text: string,
|
|
445
|
+
offset: number,
|
|
446
|
+
): number => {
|
|
447
|
+
const clamped = Math.max(0, Math.min(offset, text.length))
|
|
448
|
+
|
|
449
|
+
let lineIndex = 0
|
|
450
|
+
|
|
451
|
+
for (let index = 0; index < clamped; index += 1) {
|
|
452
|
+
if (text[index] === "\n") {
|
|
453
|
+
lineIndex += 1
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return lineIndex
|
|
458
|
+
}
|
package/src/Modal/Modal.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from "react"
|
|
1
|
+
import type { ReactNode, RefObject } from "react"
|
|
2
2
|
|
|
3
3
|
import { OverlayPanel } from "../Overlay/OverlayPanel.tsx"
|
|
4
4
|
import { toClassName } from "../toClassName.ts"
|
|
@@ -8,6 +8,10 @@ export type ModalProps = {
|
|
|
8
8
|
"aria-labelledby"?: string
|
|
9
9
|
children: ReactNode
|
|
10
10
|
className?: string
|
|
11
|
+
/** Which element takes the caret when the modal opens. Without
|
|
12
|
+
* it the focus manager takes the first tabbable element — the
|
|
13
|
+
* Close button on anything with chrome. */
|
|
14
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
11
15
|
/** Escape and a backdrop press both close. */
|
|
12
16
|
isDismissable?: boolean
|
|
13
17
|
isVisible: boolean
|
|
@@ -59,6 +63,7 @@ export const Modal = ({
|
|
|
59
63
|
"aria-labelledby": ariaLabelledBy,
|
|
60
64
|
children,
|
|
61
65
|
className,
|
|
66
|
+
initialFocus,
|
|
62
67
|
isDismissable = true,
|
|
63
68
|
isVisible,
|
|
64
69
|
onClose,
|
|
@@ -68,6 +73,7 @@ export const Modal = ({
|
|
|
68
73
|
aria-label={ariaLabel}
|
|
69
74
|
aria-labelledby={ariaLabelledBy}
|
|
70
75
|
className={toClassName(MODAL_BOX_CLASS, className)}
|
|
76
|
+
initialFocus={initialFocus}
|
|
71
77
|
isDismissable={isDismissable}
|
|
72
78
|
isVisible={isVisible}
|
|
73
79
|
onClose={onClose}
|