@wtfalch/design 0.10.2 → 0.12.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 +36 -0
- package/dist/components/DangerZone.js +1 -1
- package/dist/components/Field.d.ts +8 -1
- package/dist/components/Field.js +2 -2
- package/dist/components/RichText.d.ts +24 -0
- package/dist/components/RichText.js +69 -0
- package/dist/components/RichTextEditor.d.ts +32 -0
- package/dist/components/RichTextEditor.js +163 -0
- package/dist/components/Toggle.d.ts +8 -1
- package/dist/components/Toggle.js +2 -2
- package/dist/editor.d.ts +16 -0
- package/dist/editor.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/rich-text/schema.d.ts +219 -0
- package/dist/rich-text/schema.js +88 -0
- package/dist/rich-text/value.d.ts +25 -0
- package/dist/rich-text/value.js +41 -0
- package/dist/rich-text.d.ts +11 -0
- package/dist/rich-text.js +11 -0
- package/dist/styles/index.css +132 -21
- package/dist/tf.css +132 -21
- package/dist/valet.css +132 -21
- package/package.json +44 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* What a rich-text value is, and the only shape `RichTextEditor` produces or
|
|
4
|
+
* `RichText` draws.
|
|
5
|
+
*
|
|
6
|
+
* **A restricted document, on purpose.** The editor is TipTap, which is
|
|
7
|
+
* ProseMirror, which will model tables, colours, fonts and arbitrary nesting
|
|
8
|
+
* given the chance. A system where every writer can reach all of that is one
|
|
9
|
+
* where two pages share nothing but a logo. What is left here is
|
|
10
|
+
* paragraphs, two heading levels, two kinds of list, bold, italic and links
|
|
11
|
+
* — the set a piece of prose actually needs.
|
|
12
|
+
*
|
|
13
|
+
* **The schema is the contract between three things**: what the toolbar can
|
|
14
|
+
* produce, what a consumer should store, and what the view can draw. A
|
|
15
|
+
* consumer validates with this on the way into its database, so a crafted
|
|
16
|
+
* request can no more insert a table than the editor can, and a node the
|
|
17
|
+
* view has no case for cannot arrive.
|
|
18
|
+
*
|
|
19
|
+
* Zod is an OPTIONAL peer dependency, and this file is the only one that
|
|
20
|
+
* reaches it — which is why it ships from `@wtfalch/design/rich-text` rather
|
|
21
|
+
* than the front door. A consumer that renders prose without validating it
|
|
22
|
+
* should not have to install a validator to load the package. The types and
|
|
23
|
+
* the pure helpers live in `./value`, which imports from here with `import
|
|
24
|
+
* type` and so compiles to nothing.
|
|
25
|
+
*/
|
|
26
|
+
/** The marks a run of text may carry. `link` is the only one with a value, and its href is checked. */
|
|
27
|
+
export declare const richTextMarkSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
28
|
+
type: z.ZodLiteral<"bold">;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
type: z.ZodLiteral<"italic">;
|
|
31
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
32
|
+
type: z.ZodLiteral<"link">;
|
|
33
|
+
attrs: z.ZodObject<{
|
|
34
|
+
href: z.ZodString;
|
|
35
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
}, z.core.$strip>], "type">;
|
|
38
|
+
/** Named `RichTextMark` rather than `Mark`, which this package already uses for a product's brand mark. */
|
|
39
|
+
export type RichTextMark = z.infer<typeof richTextMarkSchema>;
|
|
40
|
+
export declare const blockNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
41
|
+
type: z.ZodLiteral<"paragraph">;
|
|
42
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
43
|
+
type: z.ZodLiteral<"text">;
|
|
44
|
+
text: z.ZodString;
|
|
45
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
46
|
+
type: z.ZodLiteral<"bold">;
|
|
47
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
48
|
+
type: z.ZodLiteral<"italic">;
|
|
49
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
50
|
+
type: z.ZodLiteral<"link">;
|
|
51
|
+
attrs: z.ZodObject<{
|
|
52
|
+
href: z.ZodString;
|
|
53
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
}, z.core.$strip>;
|
|
55
|
+
}, z.core.$strip>], "type">>>;
|
|
56
|
+
}, z.core.$strip>>>;
|
|
57
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
58
|
+
type: z.ZodLiteral<"heading">;
|
|
59
|
+
attrs: z.ZodObject<{
|
|
60
|
+
level: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>]>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
63
|
+
type: z.ZodLiteral<"text">;
|
|
64
|
+
text: z.ZodString;
|
|
65
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
66
|
+
type: z.ZodLiteral<"bold">;
|
|
67
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
68
|
+
type: z.ZodLiteral<"italic">;
|
|
69
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
70
|
+
type: z.ZodLiteral<"link">;
|
|
71
|
+
attrs: z.ZodObject<{
|
|
72
|
+
href: z.ZodString;
|
|
73
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
74
|
+
}, z.core.$strip>;
|
|
75
|
+
}, z.core.$strip>], "type">>>;
|
|
76
|
+
}, z.core.$strip>>>;
|
|
77
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
78
|
+
type: z.ZodLiteral<"bulletList">;
|
|
79
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
80
|
+
type: z.ZodLiteral<"listItem">;
|
|
81
|
+
content: z.ZodArray<z.ZodObject<{
|
|
82
|
+
type: z.ZodLiteral<"paragraph">;
|
|
83
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
84
|
+
type: z.ZodLiteral<"text">;
|
|
85
|
+
text: z.ZodString;
|
|
86
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"bold">;
|
|
88
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
89
|
+
type: z.ZodLiteral<"italic">;
|
|
90
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
91
|
+
type: z.ZodLiteral<"link">;
|
|
92
|
+
attrs: z.ZodObject<{
|
|
93
|
+
href: z.ZodString;
|
|
94
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
95
|
+
}, z.core.$strip>;
|
|
96
|
+
}, z.core.$strip>], "type">>>;
|
|
97
|
+
}, z.core.$strip>>>;
|
|
98
|
+
}, z.core.$strip>>;
|
|
99
|
+
}, z.core.$strip>>>;
|
|
100
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
101
|
+
type: z.ZodLiteral<"orderedList">;
|
|
102
|
+
attrs: z.ZodOptional<z.ZodObject<{
|
|
103
|
+
start: z.ZodNumber;
|
|
104
|
+
}, z.core.$strip>>;
|
|
105
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
106
|
+
type: z.ZodLiteral<"listItem">;
|
|
107
|
+
content: z.ZodArray<z.ZodObject<{
|
|
108
|
+
type: z.ZodLiteral<"paragraph">;
|
|
109
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
110
|
+
type: z.ZodLiteral<"text">;
|
|
111
|
+
text: z.ZodString;
|
|
112
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
113
|
+
type: z.ZodLiteral<"bold">;
|
|
114
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
115
|
+
type: z.ZodLiteral<"italic">;
|
|
116
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
117
|
+
type: z.ZodLiteral<"link">;
|
|
118
|
+
attrs: z.ZodObject<{
|
|
119
|
+
href: z.ZodString;
|
|
120
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
121
|
+
}, z.core.$strip>;
|
|
122
|
+
}, z.core.$strip>], "type">>>;
|
|
123
|
+
}, z.core.$strip>>>;
|
|
124
|
+
}, z.core.$strip>>;
|
|
125
|
+
}, z.core.$strip>>>;
|
|
126
|
+
}, z.core.$strip>], "type">;
|
|
127
|
+
export type BlockNode = z.infer<typeof blockNodeSchema>;
|
|
128
|
+
/** A whole value: what TipTap calls the document. */
|
|
129
|
+
export declare const richTextSchema: z.ZodObject<{
|
|
130
|
+
type: z.ZodLiteral<"doc">;
|
|
131
|
+
content: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
132
|
+
type: z.ZodLiteral<"paragraph">;
|
|
133
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
134
|
+
type: z.ZodLiteral<"text">;
|
|
135
|
+
text: z.ZodString;
|
|
136
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
137
|
+
type: z.ZodLiteral<"bold">;
|
|
138
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
139
|
+
type: z.ZodLiteral<"italic">;
|
|
140
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
141
|
+
type: z.ZodLiteral<"link">;
|
|
142
|
+
attrs: z.ZodObject<{
|
|
143
|
+
href: z.ZodString;
|
|
144
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
145
|
+
}, z.core.$strip>;
|
|
146
|
+
}, z.core.$strip>], "type">>>;
|
|
147
|
+
}, z.core.$strip>>>;
|
|
148
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
149
|
+
type: z.ZodLiteral<"heading">;
|
|
150
|
+
attrs: z.ZodObject<{
|
|
151
|
+
level: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>]>;
|
|
152
|
+
}, z.core.$strip>;
|
|
153
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
154
|
+
type: z.ZodLiteral<"text">;
|
|
155
|
+
text: z.ZodString;
|
|
156
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
157
|
+
type: z.ZodLiteral<"bold">;
|
|
158
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
159
|
+
type: z.ZodLiteral<"italic">;
|
|
160
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
161
|
+
type: z.ZodLiteral<"link">;
|
|
162
|
+
attrs: z.ZodObject<{
|
|
163
|
+
href: z.ZodString;
|
|
164
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
165
|
+
}, z.core.$strip>;
|
|
166
|
+
}, z.core.$strip>], "type">>>;
|
|
167
|
+
}, z.core.$strip>>>;
|
|
168
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
169
|
+
type: z.ZodLiteral<"bulletList">;
|
|
170
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
171
|
+
type: z.ZodLiteral<"listItem">;
|
|
172
|
+
content: z.ZodArray<z.ZodObject<{
|
|
173
|
+
type: z.ZodLiteral<"paragraph">;
|
|
174
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
175
|
+
type: z.ZodLiteral<"text">;
|
|
176
|
+
text: z.ZodString;
|
|
177
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
178
|
+
type: z.ZodLiteral<"bold">;
|
|
179
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
180
|
+
type: z.ZodLiteral<"italic">;
|
|
181
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
182
|
+
type: z.ZodLiteral<"link">;
|
|
183
|
+
attrs: z.ZodObject<{
|
|
184
|
+
href: z.ZodString;
|
|
185
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
186
|
+
}, z.core.$strip>;
|
|
187
|
+
}, z.core.$strip>], "type">>>;
|
|
188
|
+
}, z.core.$strip>>>;
|
|
189
|
+
}, z.core.$strip>>;
|
|
190
|
+
}, z.core.$strip>>>;
|
|
191
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
192
|
+
type: z.ZodLiteral<"orderedList">;
|
|
193
|
+
attrs: z.ZodOptional<z.ZodObject<{
|
|
194
|
+
start: z.ZodNumber;
|
|
195
|
+
}, z.core.$strip>>;
|
|
196
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
197
|
+
type: z.ZodLiteral<"listItem">;
|
|
198
|
+
content: z.ZodArray<z.ZodObject<{
|
|
199
|
+
type: z.ZodLiteral<"paragraph">;
|
|
200
|
+
content: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
201
|
+
type: z.ZodLiteral<"text">;
|
|
202
|
+
text: z.ZodString;
|
|
203
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
204
|
+
type: z.ZodLiteral<"bold">;
|
|
205
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
206
|
+
type: z.ZodLiteral<"italic">;
|
|
207
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
208
|
+
type: z.ZodLiteral<"link">;
|
|
209
|
+
attrs: z.ZodObject<{
|
|
210
|
+
href: z.ZodString;
|
|
211
|
+
target: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
212
|
+
}, z.core.$strip>;
|
|
213
|
+
}, z.core.$strip>], "type">>>;
|
|
214
|
+
}, z.core.$strip>>>;
|
|
215
|
+
}, z.core.$strip>>;
|
|
216
|
+
}, z.core.$strip>>>;
|
|
217
|
+
}, z.core.$strip>], "type">>>;
|
|
218
|
+
}, z.core.$strip>;
|
|
219
|
+
export type RichTextValue = z.infer<typeof richTextSchema>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* What a rich-text value is, and the only shape `RichTextEditor` produces or
|
|
4
|
+
* `RichText` draws.
|
|
5
|
+
*
|
|
6
|
+
* **A restricted document, on purpose.** The editor is TipTap, which is
|
|
7
|
+
* ProseMirror, which will model tables, colours, fonts and arbitrary nesting
|
|
8
|
+
* given the chance. A system where every writer can reach all of that is one
|
|
9
|
+
* where two pages share nothing but a logo. What is left here is
|
|
10
|
+
* paragraphs, two heading levels, two kinds of list, bold, italic and links
|
|
11
|
+
* — the set a piece of prose actually needs.
|
|
12
|
+
*
|
|
13
|
+
* **The schema is the contract between three things**: what the toolbar can
|
|
14
|
+
* produce, what a consumer should store, and what the view can draw. A
|
|
15
|
+
* consumer validates with this on the way into its database, so a crafted
|
|
16
|
+
* request can no more insert a table than the editor can, and a node the
|
|
17
|
+
* view has no case for cannot arrive.
|
|
18
|
+
*
|
|
19
|
+
* Zod is an OPTIONAL peer dependency, and this file is the only one that
|
|
20
|
+
* reaches it — which is why it ships from `@wtfalch/design/rich-text` rather
|
|
21
|
+
* than the front door. A consumer that renders prose without validating it
|
|
22
|
+
* should not have to install a validator to load the package. The types and
|
|
23
|
+
* the pure helpers live in `./value`, which imports from here with `import
|
|
24
|
+
* type` and so compiles to nothing.
|
|
25
|
+
*/
|
|
26
|
+
/** The marks a run of text may carry. `link` is the only one with a value, and its href is checked. */
|
|
27
|
+
export const richTextMarkSchema = z.discriminatedUnion('type', [
|
|
28
|
+
z.object({ type: z.literal('bold') }),
|
|
29
|
+
z.object({ type: z.literal('italic') }),
|
|
30
|
+
z.object({
|
|
31
|
+
type: z.literal('link'),
|
|
32
|
+
attrs: z.object({
|
|
33
|
+
/**
|
|
34
|
+
* http(s), mailto, or a path. `javascript:` and `data:` are the two
|
|
35
|
+
* that turn a link into script, and they are refused here rather than
|
|
36
|
+
* only in the editor: the editor is a convenience, this is the gate.
|
|
37
|
+
*/
|
|
38
|
+
href: z
|
|
39
|
+
.string()
|
|
40
|
+
.trim()
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(2048)
|
|
43
|
+
.refine((href) => /^(https?:\/\/|mailto:|\/)/i.test(href), 'A link must be http(s), mailto: or a path beginning with /'),
|
|
44
|
+
target: z.string().nullish(),
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
const textNode = z.object({
|
|
49
|
+
type: z.literal('text'),
|
|
50
|
+
text: z.string().max(10_000),
|
|
51
|
+
marks: z.array(richTextMarkSchema).max(8).optional(),
|
|
52
|
+
});
|
|
53
|
+
const inline = z.array(textNode).max(500).optional();
|
|
54
|
+
const paragraph = z.object({ type: z.literal('paragraph'), content: inline });
|
|
55
|
+
const heading = z.object({
|
|
56
|
+
type: z.literal('heading'),
|
|
57
|
+
/**
|
|
58
|
+
* Two levels, and not level 1: the page's own `<h1>` is its title, and a
|
|
59
|
+
* second one in the body is the most common way a document stops being
|
|
60
|
+
* navigable to somebody reading it with a screen reader.
|
|
61
|
+
*/
|
|
62
|
+
attrs: z.object({ level: z.union([z.literal(2), z.literal(3)]) }),
|
|
63
|
+
content: inline,
|
|
64
|
+
});
|
|
65
|
+
const listItem = z.object({
|
|
66
|
+
type: z.literal('listItem'),
|
|
67
|
+
content: z.array(paragraph).max(20),
|
|
68
|
+
});
|
|
69
|
+
const bulletList = z.object({
|
|
70
|
+
type: z.literal('bulletList'),
|
|
71
|
+
content: z.array(listItem).max(200).optional(),
|
|
72
|
+
});
|
|
73
|
+
const orderedList = z.object({
|
|
74
|
+
type: z.literal('orderedList'),
|
|
75
|
+
attrs: z.object({ start: z.number().int().min(1).max(999) }).optional(),
|
|
76
|
+
content: z.array(listItem).max(200).optional(),
|
|
77
|
+
});
|
|
78
|
+
export const blockNodeSchema = z.discriminatedUnion('type', [
|
|
79
|
+
paragraph,
|
|
80
|
+
heading,
|
|
81
|
+
bulletList,
|
|
82
|
+
orderedList,
|
|
83
|
+
]);
|
|
84
|
+
/** A whole value: what TipTap calls the document. */
|
|
85
|
+
export const richTextSchema = z.object({
|
|
86
|
+
type: z.literal('doc'),
|
|
87
|
+
content: z.array(blockNodeSchema).max(400).optional(),
|
|
88
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RichTextValue } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* The parts of a rich-text value that need no validator.
|
|
4
|
+
*
|
|
5
|
+
* Split from `./schema` so the package's front door stays free of zod. The
|
|
6
|
+
* schema is a runtime object and zod is an optional peer dependency, so a
|
|
7
|
+
* consumer that renders prose and never validates it — a site, a preview —
|
|
8
|
+
* would otherwise fail to load the package at all for want of a dependency
|
|
9
|
+
* it has no use for. The type import above is erased at build, so nothing
|
|
10
|
+
* here reaches zod.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* A document with nothing in it — and one empty paragraph, not an empty
|
|
14
|
+
* array. ProseMirror's schema says a document is `block+`, so a `doc` with
|
|
15
|
+
* no children is not a document it can put a cursor in: the editor rendered
|
|
16
|
+
* an empty box with no paragraph, which meant no node to mark as empty and
|
|
17
|
+
* so no placeholder. The picture of that is why this reads the way it does.
|
|
18
|
+
* `isEmptyRichText` treats both forms as empty, and the schema accepts both,
|
|
19
|
+
* because a value that arrives from somewhere else may be either.
|
|
20
|
+
*/
|
|
21
|
+
export declare const emptyRichText: RichTextValue;
|
|
22
|
+
/** Whether there is anything to draw, so a caller can skip an empty value rather than leave a gap. */
|
|
23
|
+
export declare function isEmptyRichText(value: RichTextValue): boolean;
|
|
24
|
+
/** The plain words, for a summary, a search index or a document's own `<title>`. */
|
|
25
|
+
export declare function richTextToPlain(value: RichTextValue): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a rich-text value that need no validator.
|
|
3
|
+
*
|
|
4
|
+
* Split from `./schema` so the package's front door stays free of zod. The
|
|
5
|
+
* schema is a runtime object and zod is an optional peer dependency, so a
|
|
6
|
+
* consumer that renders prose and never validates it — a site, a preview —
|
|
7
|
+
* would otherwise fail to load the package at all for want of a dependency
|
|
8
|
+
* it has no use for. The type import above is erased at build, so nothing
|
|
9
|
+
* here reaches zod.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A document with nothing in it — and one empty paragraph, not an empty
|
|
13
|
+
* array. ProseMirror's schema says a document is `block+`, so a `doc` with
|
|
14
|
+
* no children is not a document it can put a cursor in: the editor rendered
|
|
15
|
+
* an empty box with no paragraph, which meant no node to mark as empty and
|
|
16
|
+
* so no placeholder. The picture of that is why this reads the way it does.
|
|
17
|
+
* `isEmptyRichText` treats both forms as empty, and the schema accepts both,
|
|
18
|
+
* because a value that arrives from somewhere else may be either.
|
|
19
|
+
*/
|
|
20
|
+
export const emptyRichText = { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
21
|
+
/** Whether there is anything to draw, so a caller can skip an empty value rather than leave a gap. */
|
|
22
|
+
export function isEmptyRichText(value) {
|
|
23
|
+
return (value.content ?? []).every((node) => !('content' in node) || (node.content ?? []).length === 0);
|
|
24
|
+
}
|
|
25
|
+
/** The plain words, for a summary, a search index or a document's own `<title>`. */
|
|
26
|
+
export function richTextToPlain(value) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
const walk = (nodes) => {
|
|
29
|
+
for (const node of nodes) {
|
|
30
|
+
if (!node || typeof node !== 'object')
|
|
31
|
+
continue;
|
|
32
|
+
const record = node;
|
|
33
|
+
if (typeof record.text === 'string')
|
|
34
|
+
parts.push(record.text);
|
|
35
|
+
if (Array.isArray(record.content))
|
|
36
|
+
walk(record.content);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(value.content ?? []);
|
|
40
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich-text schema: `@wtfalch/design/rich-text`.
|
|
3
|
+
*
|
|
4
|
+
* What a consumer validates with on the way into its database, so that a
|
|
5
|
+
* crafted request can no more store a table than the toolbar can produce
|
|
6
|
+
* one. Separate from the front door because it is the only part of the
|
|
7
|
+
* package that needs zod, and separate from `./editor` because validating a
|
|
8
|
+
* value is a thing a server does and downloading an editor is not.
|
|
9
|
+
*/
|
|
10
|
+
export { type BlockNode, type RichTextMark, type RichTextValue, blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
|
|
11
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich-text schema: `@wtfalch/design/rich-text`.
|
|
3
|
+
*
|
|
4
|
+
* What a consumer validates with on the way into its database, so that a
|
|
5
|
+
* crafted request can no more store a table than the toolbar can produce
|
|
6
|
+
* one. Separate from the front door because it is the only part of the
|
|
7
|
+
* package that needs zod, and separate from `./editor` because validating a
|
|
8
|
+
* value is a thing a server does and downloading an editor is not.
|
|
9
|
+
*/
|
|
10
|
+
export { blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
|
|
11
|
+
export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
|
package/dist/styles/index.css
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
--text-2xs: var(--text-2xs);
|
|
38
38
|
--text-md: var(--text-md);
|
|
39
39
|
--font-weight-strong: var(--weight-strong);
|
|
40
|
+
--radius-DEFAULT: var(--radius);
|
|
40
41
|
--radius-pill: var(--radius-pill);
|
|
41
42
|
--color-text: var(--text);
|
|
42
43
|
--color-muted: var(--muted);
|
|
@@ -173,6 +174,9 @@
|
|
|
173
174
|
.min-h-0 {
|
|
174
175
|
min-height: 0px;
|
|
175
176
|
}
|
|
177
|
+
.min-h-\[calc\(var\(--space-15\)\*2\)\] {
|
|
178
|
+
min-height: calc(var(--space-15) * 2);
|
|
179
|
+
}
|
|
176
180
|
.min-h-screen {
|
|
177
181
|
min-height: 100vh;
|
|
178
182
|
}
|
|
@@ -313,6 +317,9 @@
|
|
|
313
317
|
.rounded {
|
|
314
318
|
border-radius: 0.25rem;
|
|
315
319
|
}
|
|
320
|
+
.rounded-\(--radius\) {
|
|
321
|
+
border-radius: var(--radius);
|
|
322
|
+
}
|
|
316
323
|
.rounded-full {
|
|
317
324
|
border-radius: calc(infinity * 1px);
|
|
318
325
|
}
|
|
@@ -462,6 +469,9 @@
|
|
|
462
469
|
.uppercase {
|
|
463
470
|
text-transform: uppercase;
|
|
464
471
|
}
|
|
472
|
+
.italic {
|
|
473
|
+
font-style: italic;
|
|
474
|
+
}
|
|
465
475
|
.tabular-nums {
|
|
466
476
|
--tw-numeric-spacing: tabular-nums;
|
|
467
477
|
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
|
|
@@ -508,9 +518,16 @@
|
|
|
508
518
|
.surface-bg {
|
|
509
519
|
background: var(--bg);
|
|
510
520
|
}
|
|
521
|
+
.surface-control {
|
|
522
|
+
background: var(--control);
|
|
523
|
+
}
|
|
511
524
|
.surface-panel {
|
|
512
525
|
background: var(--panel);
|
|
513
526
|
}
|
|
527
|
+
.focus-within\:shadow-\(--focus-ring\):focus-within {
|
|
528
|
+
--tw-shadow: var(--focus-ring);
|
|
529
|
+
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
530
|
+
}
|
|
514
531
|
.disabled\:cursor-default:disabled {
|
|
515
532
|
cursor: default;
|
|
516
533
|
}
|
|
@@ -1243,13 +1260,6 @@ select:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
|
1243
1260
|
to { transform: scaleX(0); }
|
|
1244
1261
|
}
|
|
1245
1262
|
|
|
1246
|
-
/* (The bordered head bar that used to live here is gone. It was a second
|
|
1247
|
-
`.card-head` -- same name, different thing -- and the only place it was ever
|
|
1248
|
-
used was the gallery page documenting it. A card that holds rows now takes
|
|
1249
|
-
`title` like any other, and the header the component draws is the one
|
|
1250
|
-
header.) */
|
|
1251
|
-
.row { display: flex; align-items: center; gap: var(--space-3); }
|
|
1252
|
-
|
|
1253
1263
|
.ctl-grow { flex: 1; min-width: 0; }
|
|
1254
1264
|
|
|
1255
1265
|
.mono { font-family: var(--font-mono); font-size: var(--text-sm); }
|
|
@@ -1510,13 +1520,6 @@ input:active, textarea:active, select:active { transform: none; }
|
|
|
1510
1520
|
template are allowed to diverge, and this is only saying that they have. */
|
|
1511
1521
|
.set-hint.drifted { color: var(--warn); }
|
|
1512
1522
|
|
|
1513
|
-
/* One applet, alone on the page, at `/applet/<id>`. Fills the window: there is
|
|
1514
|
-
no grid here to decide a height, and a screenshot of an applet letterboxed
|
|
1515
|
-
inside a page is mostly not the applet. */
|
|
1516
|
-
.bare { height: 100vh; display: flex; flex-direction: column; background: var(--bg); }
|
|
1517
|
-
|
|
1518
|
-
.bare > * { flex: 1; min-height: 0; }
|
|
1519
|
-
|
|
1520
1523
|
/* A round is in flight. On the button that asks for one, because that is where
|
|
1521
1524
|
you are looking when you have just pressed it -- and not in the foot, which
|
|
1522
1525
|
would flicker every thirty seconds on a tile that is working perfectly. */
|
|
@@ -1864,7 +1867,15 @@ input:active, textarea:active, select:active { transform: none; }
|
|
|
1864
1867
|
|
|
1865
1868
|
.danger-act > .set-hint + .set-hint { margin-top: var(--space-1); }
|
|
1866
1869
|
|
|
1867
|
-
.
|
|
1870
|
+
/* `display: flex` is here now. These rows carried `.row` as well, which is a
|
|
1871
|
+
class no component of this package renders -- it only ever reached them
|
|
1872
|
+
because a consumer's stylesheet happened to define it too. */
|
|
1873
|
+
.danger-row {
|
|
1874
|
+
display: flex;
|
|
1875
|
+
margin-top: var(--space-2);
|
|
1876
|
+
gap: var(--space-2);
|
|
1877
|
+
align-items: center;
|
|
1878
|
+
}
|
|
1868
1879
|
|
|
1869
1880
|
.danger-row > input { min-width: 0; }
|
|
1870
1881
|
|
|
@@ -1886,15 +1897,15 @@ input:active, textarea:active, select:active { transform: none; }
|
|
|
1886
1897
|
column-gap: var(--space-3);
|
|
1887
1898
|
}
|
|
1888
1899
|
|
|
1889
|
-
.field-row { align-items: flex-end; gap: var(--space-2); }
|
|
1900
|
+
.danger-field-row { align-items: flex-end; gap: var(--space-2); }
|
|
1890
1901
|
|
|
1891
|
-
.field-row > button { flex: none; }
|
|
1902
|
+
.danger-field-row > button { flex: none; }
|
|
1892
1903
|
|
|
1893
1904
|
/* Two forms one under the other, whose buttons say different words -- "Add"
|
|
1894
1905
|
and "Block". Sized to their text they came out different widths, and two
|
|
1895
1906
|
controls in the same column at the same indent that do not line up read as
|
|
1896
1907
|
an accident rather than as a pair. */
|
|
1897
|
-
.field-row > button { min-width: 9ch; }
|
|
1908
|
+
.danger-field-row > button { min-width: 9ch; }
|
|
1898
1909
|
}
|
|
1899
1910
|
|
|
1900
1911
|
/* ---- ./callout.css ---- */
|
|
@@ -2225,9 +2236,12 @@ input:active, textarea:active, select:active { transform: none; }
|
|
|
2225
2236
|
/* Sized to its words, for a switch that sits in a row of other controls rather
|
|
2226
2237
|
than owning a row of its own. `flex: 1` there would push the label away from
|
|
2227
2238
|
the switch by the whole width of the bar. */
|
|
2228
|
-
|
|
2239
|
+
/* `inRow`: a toggle beside other controls rather than in a column of
|
|
2240
|
+
settings. Was `.row > .switch-row`, which reached through `.row` -- a class
|
|
2241
|
+
no component here renders. */
|
|
2242
|
+
.switch-in-row { flex: none; padding-block: 0; }
|
|
2229
2243
|
|
|
2230
|
-
.
|
|
2244
|
+
.switch-in-row .switch-body { flex: none; }
|
|
2231
2245
|
|
|
2232
2246
|
/* The opposite case: a switch that *is* the row, with something small beside it
|
|
2233
2247
|
-- a tooltip, a state word. `.set-row` wraps, so a switch that does not grow
|
|
@@ -3816,7 +3830,10 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
|
|
|
3816
3830
|
/* Fields stacked down a form. Wider than the gap inside one field, so the
|
|
3817
3831
|
label of the next belongs to the input under it rather than to the error
|
|
3818
3832
|
above it. */
|
|
3819
|
-
|
|
3833
|
+
/* `inRow`: a field sharing a line with other controls rather than stacked in a
|
|
3834
|
+
column. Was `.field-row > .field`, which reached through a container class
|
|
3835
|
+
the caller had to write and this package does not hand out. */
|
|
3836
|
+
.field-in-row { flex: 1; min-width: 0; }
|
|
3820
3837
|
|
|
3821
3838
|
/* A password with an eye -- `Input type="password"`.
|
|
3822
3839
|
A grid rather than `position: absolute`: both children take the one cell, the
|
|
@@ -4692,3 +4709,97 @@ body.splitting * { cursor: inherit !important; }
|
|
|
4692
4709
|
.ident-remove:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
|
4693
4710
|
}
|
|
4694
4711
|
|
|
4712
|
+
/* Prose and the surface it is written on, added in 0.8.0. Last, because
|
|
4713
|
+
`.prose` styles the elements the reset in `base.css` flattened and has to
|
|
4714
|
+
win over it, and because the editor's box sits over nothing. */
|
|
4715
|
+
/* ---- ./richtext.css ---- */
|
|
4716
|
+
@layer components {
|
|
4717
|
+
/* Prose: one class, and the elements it styles are ones nobody wrote.
|
|
4718
|
+
*
|
|
4719
|
+
* That is the whole reason a class exists here rather than utilities. Inside
|
|
4720
|
+
* the editor the paragraphs, headings and lists are ProseMirror's, built
|
|
4721
|
+
* from the document — there is no JSX to hang a utility on, so the choice is
|
|
4722
|
+
* descendant rules behind one class or an arbitrary variant per element,
|
|
4723
|
+
* which is the same selector spelled worse. `Markdown` has the same problem
|
|
4724
|
+
* for the same reason and answers it the same way, as `.md` in `base.css`.
|
|
4725
|
+
*
|
|
4726
|
+
* The editor and the view share the class on purpose. They have to look
|
|
4727
|
+
* identical and one set of rules is what guarantees it; two sets maintained
|
|
4728
|
+
* separately is precisely how a what-you-see-is-what-you-get editor stops
|
|
4729
|
+
* being one. Everything the components *do* render — the box, the toolbar,
|
|
4730
|
+
* the writing surface's own padding and focus ring — is utilities in the
|
|
4731
|
+
* component, where it belongs.
|
|
4732
|
+
*
|
|
4733
|
+
* `.rich-text` and not `.prose`: this package's classes ship unprefixed into
|
|
4734
|
+
* other people's stylesheets, and `.prose` is the one name in CSS most
|
|
4735
|
+
* likely to already be taken — Tailwind's typography plugin defines exactly
|
|
4736
|
+
* it. A consumer with that plugin and this package would have had two sets
|
|
4737
|
+
* of margins fighting, and it would have read as a theming bug.
|
|
4738
|
+
*
|
|
4739
|
+
* Every value here is a token. The vertical rhythm is `--space-*`, four
|
|
4740
|
+
* pixels a step and scaling with `--density`; the type steps are `--text-*`,
|
|
4741
|
+
* scaling with `--font-size`. A heading two sizes apart from its body at one
|
|
4742
|
+
* density is two sizes apart at every density.
|
|
4743
|
+
*/
|
|
4744
|
+
|
|
4745
|
+
.rich-text > * {
|
|
4746
|
+
margin: 0;
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4749
|
+
.rich-text > * + * {
|
|
4750
|
+
margin-top: var(--space-3);
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
.rich-text h2 {
|
|
4754
|
+
font-size: var(--text-lg);
|
|
4755
|
+
font-weight: var(--weight-strong);
|
|
4756
|
+
letter-spacing: -0.01em;
|
|
4757
|
+
}
|
|
4758
|
+
|
|
4759
|
+
.rich-text h3 {
|
|
4760
|
+
font-size: var(--text-md);
|
|
4761
|
+
font-weight: var(--weight-strong);
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/* A heading after prose needs more room above it than below: the space
|
|
4765
|
+
belongs to the section it opens, not to the paragraph it follows. */
|
|
4766
|
+
.rich-text > * + :is(h2, h3) {
|
|
4767
|
+
margin-top: var(--space-6);
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
.rich-text :is(ul, ol) {
|
|
4771
|
+
padding-left: var(--space-5);
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
.rich-text li + li {
|
|
4775
|
+
margin-top: var(--space-1);
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
.rich-text a {
|
|
4779
|
+
color: var(--accent);
|
|
4780
|
+
text-decoration: underline;
|
|
4781
|
+
text-underline-offset: 2px;
|
|
4782
|
+
}
|
|
4783
|
+
|
|
4784
|
+
.rich-text strong {
|
|
4785
|
+
font-weight: var(--weight-strong);
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
/* An empty editor says what goes in it. The placeholder extension puts the
|
|
4789
|
+
words on the empty node as `data-placeholder` and marks it, so `attr()` is
|
|
4790
|
+
reading an attribute of the element it is drawn on — which is the part a
|
|
4791
|
+
hand-rolled rule got wrong twice: a rule on the paragraph cannot reach an
|
|
4792
|
+
attribute on the box, and ProseMirror's empty paragraph holds a trailing
|
|
4793
|
+
`<br>` so `:empty` never matches it either. `pointer-events: none` so a
|
|
4794
|
+
click on the words still lands in the text.
|
|
4795
|
+
|
|
4796
|
+
Harmless on the view, which never produces the class. */
|
|
4797
|
+
.rich-text p.is-editor-empty:first-child::before {
|
|
4798
|
+
content: attr(data-placeholder);
|
|
4799
|
+
float: left;
|
|
4800
|
+
height: 0;
|
|
4801
|
+
color: var(--muted);
|
|
4802
|
+
pointer-events: none;
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
|