nuvra 0.6.0 → 0.7.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 +474 -455
- package/dist/components/document-editor.vue.d.ts +8 -2
- package/dist/components/editor-toolbar.vue.d.ts +3 -1
- package/dist/core/collaboration.d.ts +3 -3
- package/dist/core/docx/import.d.ts +1 -1
- package/dist/core/engine/clipboard.d.ts +1 -1
- package/dist/core/engine/schema.d.ts +6 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/editor.vue.d.ts +5 -2
- package/dist/form.vue.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1776 -1635
- package/dist/index.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +11 -12
package/README.md
CHANGED
|
@@ -1,455 +1,474 @@
|
|
|
1
|
-
# nuvra
|
|
2
|
-
|
|
3
|
-
Word-style document editor for Vue 3. A real page view with paper sizes and margins, a lighter web view for
|
|
4
|
-
forms, tables, images, lists, find & replace, comments, HTML source mode, printing, PDF download, export to HTML and Word
|
|
5
|
-
files (`.docx`) you can open again.
|
|
6
|
-
|
|
7
|
-
**[Documentation and live demo →](https://nuvra-docs.vercel.app)**
|
|
8
|
-
|
|
9
|
-
- No editor framework underneath: its own small editing engine with a sanitizing schema.
|
|
10
|
-
- No UI framework either: native HTML controls, built-in SVG icons and plain CSS variables. Vue is the only
|
|
11
|
-
dependency.
|
|
12
|
-
- Pagination in the page view (A4, A5, Letter, …, portrait or landscape), with section breaks for landscape pages inside a
|
|
13
|
-
portrait document.
|
|
14
|
-
- Tables with merge / split, images with resize and alignment, task lists, links, colors, fonts.
|
|
15
|
-
- Undo / redo, keyboard shortcuts that work with non-Latin keyboard layouts, Markdown-like input rules.
|
|
16
|
-
- Office tools: format painter, letter case, paragraph spacing, formatting marks, a right-click menu and
|
|
17
|
-
Ctrl + wheel zoom.
|
|
18
|
-
- Headers and footers with page number, page count, date and title tokens; printing breaks the pages exactly where
|
|
19
|
-
the editor shows them, and the Word export uses Word's own header, footer and page fields.
|
|
20
|
-
- A ruler above the page for the margins and for the first line, left and right indents of a paragraph.
|
|
21
|
-
- A page watermark such as DRAFT or COPY, drawn behind the text of every page and carried into print, HTML and Word.
|
|
22
|
-
- Real `.docx` export and import, PDF download without the print dialog, multilevel (1.1.1) numbering, a table of
|
|
23
|
-
contents and a navigation pane with headings and page thumbnails.
|
|
24
|
-
- Footnotes at the bottom of their page, carried into print and Word.
|
|
25
|
-
- Comments with replies, tracked changes that round-trip with Word, a comparison of two versions, and templates
|
|
26
|
-
filled in as a form.
|
|
27
|
-
- Hooks for editing together: other people's carets and selections, your selection as character positions.
|
|
28
|
-
- A `/` command menu and a toolbar slot for your own commands and buttons.
|
|
29
|
-
|
|
30
|
-
## Installation
|
|
31
|
-
|
|
32
|
-
```sh
|
|
33
|
-
pnpm add nuvra
|
|
34
|
-
# or
|
|
35
|
-
npm install nuvra
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
`vue` (3.5+) is the only peer dependency.
|
|
39
|
-
|
|
40
|
-
Import the styles once, for example in `main.ts`:
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
import 'nuvra/style.css';
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Usage
|
|
47
|
-
|
|
48
|
-
### Document editor
|
|
49
|
-
|
|
50
|
-
```vue
|
|
51
|
-
<script setup lang="ts">
|
|
52
|
-
import { ref } from 'vue';
|
|
53
|
-
import { DocumentEditor, type PageSettings, createPageSettings } from 'nuvra';
|
|
54
|
-
|
|
55
|
-
const html = ref('');
|
|
56
|
-
const page = ref<PageSettings>(createPageSettings());
|
|
57
|
-
</script>
|
|
58
|
-
|
|
59
|
-
<template>
|
|
60
|
-
<DocumentEditor v-model="html" v-model:page="page" height="100%" title="Contract" />
|
|
61
|
-
</template>
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Form field
|
|
65
|
-
|
|
66
|
-
`Editor` is the same editor in the web view, growing with its content between `minHeight` and `maxHeight`:
|
|
67
|
-
|
|
68
|
-
```vue
|
|
69
|
-
<script setup lang="ts">
|
|
70
|
-
import { ref } from 'vue';
|
|
71
|
-
import { Editor } from 'nuvra';
|
|
72
|
-
|
|
73
|
-
const description = ref('');
|
|
74
|
-
</script>
|
|
75
|
-
|
|
76
|
-
<template>
|
|
77
|
-
<Editor v-model="description" placeholder="Description" :max-length="2000" />
|
|
78
|
-
</template>
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### Image upload
|
|
82
|
-
|
|
83
|
-
Without an upload handler images are embedded as data URLs. Pass `uploadImage` to store them on your server:
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
import type { DocumentImageUploadHandler } from 'nuvra';
|
|
87
|
-
|
|
88
|
-
const uploadImage: DocumentImageUploadHandler = async file => {
|
|
89
|
-
const body = new FormData();
|
|
90
|
-
body.append('file', file);
|
|
91
|
-
const response = await fetch('/api/files', { method: 'POST', body });
|
|
92
|
-
return (await response.json()).url;
|
|
93
|
-
};
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
## API
|
|
97
|
-
|
|
98
|
-
### `DocumentEditor` props
|
|
99
|
-
|
|
100
|
-
| Prop | Type | Default | Description |
|
|
101
|
-
| ----------------- | --------------------------------- | -------- | -------------------------------------------------------------------------- |
|
|
102
|
-
| `v-model` | `string` | `''` | Document HTML; an empty document is an empty string. |
|
|
103
|
-
| `v-model:page` | `PageSettings` | A4 | Paper size, orientation, margins, headers, footers, watermark, numbering. |
|
|
104
|
-
| `v-model:comments`| `DocumentComment[]` | — | Comments; binding it turns the comment tools on. |
|
|
105
|
-
| `v-model:trackChanges` | `boolean` | `false` | Records edits as tracked changes. |
|
|
106
|
-
| `author` | `string` | `''` | Name written on new comments, replies and tracked changes. |
|
|
107
|
-
| `autofocus` | `boolean` | `false` | Places the caret at the end of the document once ready. |
|
|
108
|
-
| `canvasPadding` | `number \| string` | `50` | Gray space around the page or web sheet. |
|
|
109
|
-
| `collaborators` | `Collaborator[]` | `[]` | Other people editing the document; their carets and selections are drawn. |
|
|
110
|
-
| `defaultViewMode` | `'page' \| 'web'` | `'page'` | View shown first. |
|
|
111
|
-
| `disabled` | `boolean` | `false` | Read-only document, disabled controls. |
|
|
112
|
-
| `height` | `number \| string` | `760` | Height of the editor, or `'auto'` to grow between `minHeight`/`maxHeight`. |
|
|
113
|
-
| `locale` | `EditorLocale \| 'uz' \| 'en' \| 'ru'` | — | Interface language; defaults to the app-wide language (Uzbek). |
|
|
114
|
-
| `minHeight` | `number \| string` | `240` | Smallest height of an auto-height editor. |
|
|
115
|
-
| `maxHeight` | `number \| string` | `600` | Largest height of an auto-height editor. |
|
|
116
|
-
| `maxImageSizeMb` | `number` | `10` | Largest accepted image file. |
|
|
117
|
-
| `maxLength` | `number` | `0` | Character limit; `0` means unlimited. |
|
|
118
|
-
| `placeholder` | `string` | `''` | Text shown while the document is empty. |
|
|
119
|
-
| `ruler` | `boolean` | `true` | Shows the ruler in the page view. |
|
|
120
|
-
| `slashCommands` | `SlashCommand[]` | `[]` | Your own commands, listed first in the `/` menu. |
|
|
121
|
-
| `title` | `string` | `''` | Print title and exported file name. |
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
`
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{ name: '
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
import {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
</
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
</
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
|
427
|
-
|
|
|
428
|
-
| `--nuvra-
|
|
429
|
-
| `--nuvra-
|
|
430
|
-
| `--nuvra-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1
|
+
# nuvra
|
|
2
|
+
|
|
3
|
+
Word-style document editor for Vue 3. A real page view with paper sizes and margins, a lighter web view for
|
|
4
|
+
forms, tables, images, lists, find & replace, comments, HTML source mode, printing, PDF download, export to HTML and Word
|
|
5
|
+
files (`.docx`) you can open again.
|
|
6
|
+
|
|
7
|
+
**[Documentation and live demo →](https://nuvra-docs.vercel.app)**
|
|
8
|
+
|
|
9
|
+
- No editor framework underneath: its own small editing engine with a sanitizing schema.
|
|
10
|
+
- No UI framework either: native HTML controls, built-in SVG icons and plain CSS variables. Vue is the only
|
|
11
|
+
dependency.
|
|
12
|
+
- Pagination in the page view (A4, A5, Letter, …, portrait or landscape), with section breaks for landscape pages inside a
|
|
13
|
+
portrait document.
|
|
14
|
+
- Tables with merge / split, images with resize and alignment, task lists, links, colors, fonts.
|
|
15
|
+
- Undo / redo, keyboard shortcuts that work with non-Latin keyboard layouts, Markdown-like input rules.
|
|
16
|
+
- Office tools: format painter, letter case, paragraph spacing, formatting marks, a right-click menu and
|
|
17
|
+
Ctrl + wheel zoom.
|
|
18
|
+
- Headers and footers with page number, page count, date and title tokens; printing breaks the pages exactly where
|
|
19
|
+
the editor shows them, and the Word export uses Word's own header, footer and page fields.
|
|
20
|
+
- A ruler above the page for the margins and for the first line, left and right indents of a paragraph.
|
|
21
|
+
- A page watermark such as DRAFT or COPY, drawn behind the text of every page and carried into print, HTML and Word.
|
|
22
|
+
- Real `.docx` export and import, PDF download without the print dialog, multilevel (1.1.1) numbering, a table of
|
|
23
|
+
contents and a navigation pane with headings and page thumbnails.
|
|
24
|
+
- Footnotes at the bottom of their page, carried into print and Word.
|
|
25
|
+
- Comments with replies, tracked changes that round-trip with Word, a comparison of two versions, and templates
|
|
26
|
+
filled in as a form.
|
|
27
|
+
- Hooks for editing together: other people's carets and selections, your selection as character positions.
|
|
28
|
+
- A `/` command menu and a toolbar slot for your own commands and buttons.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pnpm add nuvra
|
|
34
|
+
# or
|
|
35
|
+
npm install nuvra
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`vue` (3.5+) is the only peer dependency.
|
|
39
|
+
|
|
40
|
+
Import the styles once, for example in `main.ts`:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import 'nuvra/style.css';
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### Document editor
|
|
49
|
+
|
|
50
|
+
```vue
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
import { ref } from 'vue';
|
|
53
|
+
import { DocumentEditor, type PageSettings, createPageSettings } from 'nuvra';
|
|
54
|
+
|
|
55
|
+
const html = ref('');
|
|
56
|
+
const page = ref<PageSettings>(createPageSettings());
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<DocumentEditor v-model="html" v-model:page="page" height="100%" title="Contract" />
|
|
61
|
+
</template>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Form field
|
|
65
|
+
|
|
66
|
+
`Editor` is the same editor in the web view, growing with its content between `minHeight` and `maxHeight`:
|
|
67
|
+
|
|
68
|
+
```vue
|
|
69
|
+
<script setup lang="ts">
|
|
70
|
+
import { ref } from 'vue';
|
|
71
|
+
import { Editor } from 'nuvra';
|
|
72
|
+
|
|
73
|
+
const description = ref('');
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<Editor v-model="description" placeholder="Description" :max-length="2000" />
|
|
78
|
+
</template>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Image upload
|
|
82
|
+
|
|
83
|
+
Without an upload handler images are embedded as data URLs. Pass `uploadImage` to store them on your server:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import type { DocumentImageUploadHandler } from 'nuvra';
|
|
87
|
+
|
|
88
|
+
const uploadImage: DocumentImageUploadHandler = async file => {
|
|
89
|
+
const body = new FormData();
|
|
90
|
+
body.append('file', file);
|
|
91
|
+
const response = await fetch('/api/files', { method: 'POST', body });
|
|
92
|
+
return (await response.json()).url;
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
### `DocumentEditor` props
|
|
99
|
+
|
|
100
|
+
| Prop | Type | Default | Description |
|
|
101
|
+
| ----------------- | --------------------------------- | -------- | -------------------------------------------------------------------------- |
|
|
102
|
+
| `v-model` | `string` | `''` | Document HTML; an empty document is an empty string. |
|
|
103
|
+
| `v-model:page` | `PageSettings` | A4 | Paper size, orientation, margins, headers, footers, watermark, numbering. |
|
|
104
|
+
| `v-model:comments`| `DocumentComment[]` | — | Comments; binding it turns the comment tools on. |
|
|
105
|
+
| `v-model:trackChanges` | `boolean` | `false` | Records edits as tracked changes. |
|
|
106
|
+
| `author` | `string` | `''` | Name written on new comments, replies and tracked changes. |
|
|
107
|
+
| `autofocus` | `boolean` | `false` | Places the caret at the end of the document once ready. |
|
|
108
|
+
| `canvasPadding` | `number \| string` | `50` | Gray space around the page or web sheet. |
|
|
109
|
+
| `collaborators` | `Collaborator[]` | `[]` | Other people editing the document; their carets and selections are drawn. |
|
|
110
|
+
| `defaultViewMode` | `'page' \| 'web'` | `'page'` | View shown first. |
|
|
111
|
+
| `disabled` | `boolean` | `false` | Read-only document, disabled controls. |
|
|
112
|
+
| `height` | `number \| string` | `760` | Height of the editor, or `'auto'` to grow between `minHeight`/`maxHeight`. |
|
|
113
|
+
| `locale` | `EditorLocale \| 'uz' \| 'en' \| 'ru'` | — | Interface language; defaults to the app-wide language (Uzbek). |
|
|
114
|
+
| `minHeight` | `number \| string` | `240` | Smallest height of an auto-height editor. |
|
|
115
|
+
| `maxHeight` | `number \| string` | `600` | Largest height of an auto-height editor. |
|
|
116
|
+
| `maxImageSizeMb` | `number` | `10` | Largest accepted image file. |
|
|
117
|
+
| `maxLength` | `number` | `0` | Character limit; `0` means unlimited. |
|
|
118
|
+
| `placeholder` | `string` | `''` | Text shown while the document is empty. |
|
|
119
|
+
| `ruler` | `boolean` | `true` | Shows the ruler in the page view. |
|
|
120
|
+
| `slashCommands` | `SlashCommand[]` | `[]` | Your own commands, listed first in the `/` menu. |
|
|
121
|
+
| `title` | `string` | `''` | Print title and exported file name. |
|
|
122
|
+
| `tools` | `ToolbarTool[]` | all | Toolbar tools to show; see [Choosing the toolbar tools](#choosing-the-toolbar-tools). |
|
|
123
|
+
| `uploadImage` | `(file: File) => Promise<string>` | — | Uploads an image and resolves with its URL. |
|
|
124
|
+
| `variables` | `TemplateVariable[]` | `[]` | Template variables the user can insert. |
|
|
125
|
+
|
|
126
|
+
`Editor` accepts the same props except `v-model:page`, `v-model:comments`, `v-model:trackChanges`, `author`,
|
|
127
|
+
`collaborators`, `defaultViewMode`, `height`, `ruler`, `slashCommands` and `title`.
|
|
128
|
+
|
|
129
|
+
Numbers are pixels; strings are used as CSS lengths (`'100%'`, `'50vh'`).
|
|
130
|
+
|
|
131
|
+
### Events
|
|
132
|
+
|
|
133
|
+
| Event | Payload | Description |
|
|
134
|
+
| ----------------- | ----------------------------------- | -------------------------------------------------------------- |
|
|
135
|
+
| `focus` | — | The document received focus. |
|
|
136
|
+
| `blur` | — | The document lost focus; the model is up to date. |
|
|
137
|
+
| `uploadError` | `error: unknown` | An image was rejected or its upload failed. |
|
|
138
|
+
| `importError` | `error: unknown` | A Word file could not be read. |
|
|
139
|
+
| `exportError` | `error: unknown` | The PDF could not be drawn; no file is downloaded. |
|
|
140
|
+
| `selectionChange` | `selection: SelectionOffsets \| null` | The caret or selection moved; `null` when it left the document. |
|
|
141
|
+
|
|
142
|
+
### Exposed methods (`DocumentEditor` ref)
|
|
143
|
+
|
|
144
|
+
| Method | Description |
|
|
145
|
+
| ------------------------- | ---------------------------------------------------------------- |
|
|
146
|
+
| `focus()` | Moves keyboard focus into the document. |
|
|
147
|
+
| `getHTML()` | Returns the document HTML, including edits not yet in the model. |
|
|
148
|
+
| `insertVariable(name)` | Inserts a template variable at the selection. |
|
|
149
|
+
| `updateTableOfContents()` | Inserts or refreshes the table of contents. |
|
|
150
|
+
| `importWord(file)` | Replaces the document with the content of a `.docx` file. |
|
|
151
|
+
| `print()` | Opens the browser print dialog. |
|
|
152
|
+
| `exportHtml()` | Downloads the document as an HTML page. |
|
|
153
|
+
| `exportWord()` | Downloads the document as a Word file (`.docx`). |
|
|
154
|
+
| `exportPdf()` | Downloads the document as a PDF drawn from its pages. |
|
|
155
|
+
| `engine` | The editing engine (`DocumentEngine`), for advanced integrations. |
|
|
156
|
+
|
|
157
|
+
### Keyboard shortcuts
|
|
158
|
+
|
|
159
|
+
`Ctrl/⌘+B`, `I`, `U` — bold, italic, underline · `Ctrl/⌘+Shift+H` — highlight · `Ctrl/⌘+Z`, `Ctrl/⌘+Shift+Z` — undo,
|
|
160
|
+
redo · `Ctrl/⌘+F` — find · `Ctrl/⌘+H` — replace · `Ctrl/⌘+K` — link · `Ctrl/⌘+Alt+M` — comment · `Ctrl/⌘+P` — print.
|
|
161
|
+
|
|
162
|
+
The full list, with Markdown-like input rules, is in the
|
|
163
|
+
[keyboard shortcuts guide](https://nuvra-docs.vercel.app/docs/keyboard-shortcuts).
|
|
164
|
+
|
|
165
|
+
## Templates and signatures
|
|
166
|
+
|
|
167
|
+
Pass `variables` to let users insert template variables with the toolbar's **{ }** menu or by typing `{{name}}`.
|
|
168
|
+
They are saved as `<span data-variable="name">{{name}}</span>`; `fillTemplate` replaces them with escaped values,
|
|
169
|
+
in the browser or on a Node server:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { type TemplateVariable, fillTemplate } from 'nuvra';
|
|
173
|
+
|
|
174
|
+
const variables: TemplateVariable[] = [
|
|
175
|
+
{ name: 'full_name', label: 'Full name' },
|
|
176
|
+
{ name: 'letter_date', label: 'Letter date' }
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const letter = fillTemplate(template, { full_name: 'Aziz Karimov', letter_date: '14.09.2026' });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The pen button inserts a signature block in the editor's language: a signature line, an “Approved” or “Agreed”
|
|
183
|
+
block, or the signatures of both parties of a contract. It is a borderless table, so it prints and exports to Word
|
|
184
|
+
without lines.
|
|
185
|
+
|
|
186
|
+
The document button inserts ready-made templates (official letter, order, application, certificate, act), and the
|
|
187
|
+
toolbar writes amounts in words (`15 000 000 (o‘n besh million)`), inserts long dates (`2026-yil 14-sentabr`) and
|
|
188
|
+
converts Uzbek text between the Latin and Cyrillic alphabets. The same helpers are exported: `getDocumentTemplate`,
|
|
189
|
+
`numberToWords`, `formatAmountInWords`, `parseAmount`, `formatLongDate` and `transliterate`.
|
|
190
|
+
|
|
191
|
+
### Filling a template as a form
|
|
192
|
+
|
|
193
|
+
`DocumentForm` shows a saved template as it will be printed, with an input in place of every variable. Fields of the
|
|
194
|
+
same variable share one value:
|
|
195
|
+
|
|
196
|
+
```vue
|
|
197
|
+
<script setup lang="ts">
|
|
198
|
+
import { ref } from 'vue';
|
|
199
|
+
import { DocumentForm } from 'nuvra';
|
|
200
|
+
|
|
201
|
+
const values = ref<Record<string, string>>({});
|
|
202
|
+
const form = ref<InstanceType<typeof DocumentForm>>();
|
|
203
|
+
|
|
204
|
+
const submit = () => {
|
|
205
|
+
if (form.value?.validate().length) return; // empty fields are marked and focused
|
|
206
|
+
const html = form.value?.getHTML(); // filled with fillTemplate
|
|
207
|
+
};
|
|
208
|
+
</script>
|
|
209
|
+
|
|
210
|
+
<template>
|
|
211
|
+
<DocumentForm ref="form" v-model="values" :template="template" :variables="variables" />
|
|
212
|
+
</template>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Word files and long documents
|
|
216
|
+
|
|
217
|
+
"Download as Word (.docx)" in the "More" menu writes a real Office Open XML file with the page setup, headers and
|
|
218
|
+
footers with page fields, the watermark, lists, tables with merged cells, images, footnotes and tracked changes as
|
|
219
|
+
Word revisions. "Open Word file (.docx)" (or `importWord(file)`) loads one into the editor, footnotes and revisions
|
|
220
|
+
included; a file that cannot be read emits `importError`. `buildDocx` and `readDocx` do the same in your own code. A
|
|
221
|
+
document with several sections takes the page setup of its first section, and every further section starts with a
|
|
222
|
+
section break; the content of a different first page header is not imported.
|
|
223
|
+
|
|
224
|
+
Tables longer than a page break between their rows in the page view, in print and in the PDF, as in Word: the rows
|
|
225
|
+
that do not fit continue at the top of the next page, and rows joined by a merged cell stay together.
|
|
226
|
+
|
|
227
|
+
### PDF download
|
|
228
|
+
|
|
229
|
+
"Download as PDF" in the "More" menu, or `exportPdf()`, saves a PDF without the print dialog. Every sheet is drawn into a
|
|
230
|
+
picture (SVG `foreignObject` → canvas → JPEG) and written into the PDF, so it looks like the printout, but its text
|
|
231
|
+
cannot be selected or searched. Images from servers without CORS are left out, and in the web view the editor switches to
|
|
232
|
+
the page view for the moment of the export. A PDF that cannot be drawn emits `exportError`; printing with "Save as PDF"
|
|
233
|
+
still gives a PDF with real text.
|
|
234
|
+
|
|
235
|
+
### Pages in different orientations
|
|
236
|
+
|
|
237
|
+
"Section break: landscape pages" and "Section break: portrait pages" in the insert menu and the `/` menu (or
|
|
238
|
+
`engine.insertSectionBreak('landscape')`) turn the pages after the break; paper size and margins stay, and the break
|
|
239
|
+
starts a new page. It is saved as `<div data-type="section-break" data-orientation="landscape"></div>`. The page view
|
|
240
|
+
draws sheets of different sizes and gives the blocks of a turned section their sheet's text width; printing uses a named
|
|
241
|
+
`@page` for turned sheets, the PDF has turned pages, and the Word export writes every section as a Word section with its
|
|
242
|
+
own orientation.
|
|
243
|
+
|
|
244
|
+
### Footnotes
|
|
245
|
+
|
|
246
|
+
"Footnote" in the insert menu or the `/` menu adds a numbered reference and opens a small form for the note; clicking
|
|
247
|
+
a reference edits or deletes it. A footnote is saved in the reference, `<sup data-footnote="note text">1</sup>`, and
|
|
248
|
+
renumbered in document order. The page view draws the notes at the bottom of the sheet the reference is on, the web
|
|
249
|
+
view after the document; printing follows the sheets, and the Word export writes Word footnotes. Notes are plain text.
|
|
250
|
+
From code: `engine.insertFootnote(text)`, `setFootnoteText(element, text)`, `removeFootnote(element)`,
|
|
251
|
+
`getFootnotes()`.
|
|
252
|
+
|
|
253
|
+
For long documents the toolbar offers multilevel numbering (1., 1.1., 1.1.1., saved as `<ol data-numbering="legal">`),
|
|
254
|
+
a table of contents of the headings with page numbers (saved as `<table data-type="toc">`, refreshed with
|
|
255
|
+
`updateTableOfContents()`), and a navigation pane. `PageSettings` has `differentFirstPage` to hide the header, footer
|
|
256
|
+
and page number on the first page, and `firstPageNumber` for the number printed on it. The navigation pane lists the
|
|
257
|
+
headings or shows page thumbnails; clicking one scrolls to it.
|
|
258
|
+
|
|
259
|
+
## Comments, tracked changes and comparison
|
|
260
|
+
|
|
261
|
+
Bind `v-model:comments` to turn comments on. The HTML keeps only the anchors, `<span data-comment="id">`; the
|
|
262
|
+
comments are plain data you store next to the document:
|
|
263
|
+
|
|
264
|
+
```vue
|
|
265
|
+
<script setup lang="ts">
|
|
266
|
+
import { ref } from 'vue';
|
|
267
|
+
import { DocumentEditor, type DocumentComment } from 'nuvra';
|
|
268
|
+
|
|
269
|
+
const html = ref('');
|
|
270
|
+
const comments = ref<DocumentComment[]>([]);
|
|
271
|
+
|
|
272
|
+
const save = () =>
|
|
273
|
+
fetch('/api/documents/42', {
|
|
274
|
+
method: 'PUT',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ html: html.value, comments: comments.value })
|
|
277
|
+
});
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<template>
|
|
281
|
+
<DocumentEditor v-model="html" v-model:comments="comments" author="Aziz Karimov" @blur="save" />
|
|
282
|
+
</template>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The comment and change tools are in the **Review** menu of the toolbar. Users select text and choose "Add comment"
|
|
286
|
+
(`Ctrl/⌘+Alt+M`); the "Comments" panel replies, resolves, reopens and deletes them, and flags comments whose text was
|
|
287
|
+
deleted.
|
|
288
|
+
|
|
289
|
+
### Tracked changes
|
|
290
|
+
|
|
291
|
+
Bind `v-model:trackChanges` (or choose "Track changes" in the Review menu) to record typing, deleting, cut and paste as tracked changes
|
|
292
|
+
by `author`. They are part of the HTML, `<ins data-change="id" data-author="…" data-time="…">` and `<del …>`, shown
|
|
293
|
+
and printed green-underlined and red-struck. The "Changes" panel accepts or rejects them one by one or all at once;
|
|
294
|
+
from code use `engine.getChanges()`, `engine.resolveChanges(accept, id?)` and `engine.selectChange(id)`. Formatting,
|
|
295
|
+
block changes (headings, lists, tables), Enter and joining paragraphs are not tracked. The Word export writes them as
|
|
296
|
+
revisions and the import reads Word revisions back.
|
|
297
|
+
|
|
298
|
+
```vue
|
|
299
|
+
<DocumentEditor v-model="html" v-model:track-changes="tracking" author="Aziz Karimov" />
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`DocumentCompare` shows what changed between two versions: inserted words in green, deleted words struck through in
|
|
303
|
+
red. Unchanged blocks stay as they are, changed paragraphs are compared word by word, and added or removed blocks are
|
|
304
|
+
shown whole. `compareDocuments(before, after)` returns the same HTML and counts for your own view.
|
|
305
|
+
|
|
306
|
+
```vue
|
|
307
|
+
<DocumentCompare :before="previousVersion" :after="html" :height="600" />
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Editing together
|
|
311
|
+
|
|
312
|
+
The editor has hooks for several people on one document; the transport (WebSocket, WebRTC, …) is up to your app.
|
|
313
|
+
`selectionChange` reports your caret or selection as character positions, `collaborators` draws other people's carets
|
|
314
|
+
(with names) and selections, and a new `v-model` value from outside keeps your caret at the same character position.
|
|
315
|
+
The engine also offers `getSelectionOffsets()`, `getOffsetRects(offsets)` and `setContent(html, { keepSelection })`;
|
|
316
|
+
`collaboratorColor` gives the colour a collaborator is drawn in.
|
|
317
|
+
|
|
318
|
+
```vue
|
|
319
|
+
<script setup lang="ts">
|
|
320
|
+
import { ref } from 'vue';
|
|
321
|
+
import { type Collaborator, DocumentEditor, type SelectionOffsets } from 'nuvra';
|
|
322
|
+
|
|
323
|
+
const html = ref('');
|
|
324
|
+
const collaborators = ref<Collaborator[]>([]); // filled from your socket messages
|
|
325
|
+
|
|
326
|
+
const sendSelection = (selection: SelectionOffsets | null) =>
|
|
327
|
+
socket.send(JSON.stringify({ type: 'selection', id: me.id, name: me.name, selection }));
|
|
328
|
+
</script>
|
|
329
|
+
|
|
330
|
+
<template>
|
|
331
|
+
<DocumentEditor v-model="html" :collaborators="collaborators" @selection-change="sendSelection" />
|
|
332
|
+
</template>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
This is not a CRDT: the document travels as a whole, so when two people type at the same time the document sent last
|
|
336
|
+
wins, and because positions are character offsets, a remote edit before your caret shifts it. A library such as Yjs
|
|
337
|
+
can carry the document and the selections (awareness), but edits are still not merged character by character. See the
|
|
338
|
+
[guide](https://nuvra-docs.vercel.app/docs/collaboration).
|
|
339
|
+
|
|
340
|
+
## Commands and toolbar buttons
|
|
341
|
+
|
|
342
|
+
Typing `/` at the start of a line or after a space opens a command menu: headings, lists, table, page and section breaks, footnote,
|
|
343
|
+
table of contents, dates, signature blocks and your variables. `slashCommands` adds your own commands at the top, and the
|
|
344
|
+
`toolbar` slot adds your own buttons:
|
|
345
|
+
|
|
346
|
+
```vue
|
|
347
|
+
<script setup lang="ts">
|
|
348
|
+
import { DocumentEditor, type SlashCommand } from 'nuvra';
|
|
349
|
+
|
|
350
|
+
const slashCommands: SlashCommand[] = [
|
|
351
|
+
{ id: 'director', label: 'Director’s name', icon: 'pencil', run: engine => engine.insertText('A. Karimov') }
|
|
352
|
+
];
|
|
353
|
+
</script>
|
|
354
|
+
|
|
355
|
+
<template>
|
|
356
|
+
<DocumentEditor v-model="html" :slash-commands="slashCommands">
|
|
357
|
+
<template #toolbar="{ engine, disabled }">
|
|
358
|
+
<button type="button" class="doc-tb-button" :disabled="disabled" @mousedown.prevent @click="engine.insertText('✓')">
|
|
359
|
+
✓
|
|
360
|
+
</button>
|
|
361
|
+
</template>
|
|
362
|
+
</DocumentEditor>
|
|
363
|
+
</template>
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Choosing the toolbar tools
|
|
367
|
+
|
|
368
|
+
The `tools` prop shows only the toolbar tools you list, in their usual order; without it every tool is shown. Groups left
|
|
369
|
+
without a tool disappear together with their dividers, and the `toolbar` slot is always shown. Keyboard shortcuts, the
|
|
370
|
+
`/` menu and the right-click menu are not affected.
|
|
371
|
+
|
|
372
|
+
```vue
|
|
373
|
+
<DocumentEditor v-model="html" :tools="['history', 'blockStyle', 'marks', 'lists', 'link', 'table']" />
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The tools are `history` (undo, redo), `formatPainter`, `blockStyle`, `fontFamily`, `fontSize`, `marks` (bold, italic,
|
|
377
|
+
underline and more), `textCase`, `color`, `highlight`, `align`, `lineHeight`, `direction`, `lists`, `indent`, `link`,
|
|
378
|
+
`image`, `table`, `specialCharacters`, `variables`, `signature`, `insert`, `review`, `search`, `templates`,
|
|
379
|
+
`headerFooter`, `pageSetup` and `more`. `TOOLBAR_TOOLS` lists them all, and `Editor` takes the same prop.
|
|
380
|
+
|
|
381
|
+
## Languages
|
|
382
|
+
|
|
383
|
+
The interface ships in Uzbek (`uz`, the default), Uzbek Cyrillic (`uzCyrl`), English (`en`) and Russian (`ru`). The translations are part of
|
|
384
|
+
the package and cannot be changed from outside; an app only picks the language.
|
|
385
|
+
|
|
386
|
+
For one editor, pass the locale (or just its code) to the `locale` prop:
|
|
387
|
+
|
|
388
|
+
```vue
|
|
389
|
+
<script setup lang="ts">
|
|
390
|
+
import { DocumentEditor, ru } from 'nuvra';
|
|
391
|
+
</script>
|
|
392
|
+
|
|
393
|
+
<template>
|
|
394
|
+
<DocumentEditor v-model="html" :locale="ru" />
|
|
395
|
+
</template>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
For the whole app, call `setEditorLocale` once, for example in `main.ts`. It is reactive, so calling it again from a
|
|
399
|
+
language switcher updates editors already on the page:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
import { en, setEditorLocale } from 'nuvra';
|
|
403
|
+
|
|
404
|
+
setEditorLocale(en);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The `locale` prop wins over `setEditorLocale`; without either the editor is in Uzbek. `editorLocales` lists every
|
|
408
|
+
built-in locale with its name, for language pickers.
|
|
409
|
+
|
|
410
|
+
## Theming
|
|
411
|
+
|
|
412
|
+
Colors come from CSS variables declared on `.document-editor` with zero specificity, so any rule that targets
|
|
413
|
+
`.document-editor` overrides them, whatever order the stylesheets load in. Set them on the editor element itself:
|
|
414
|
+
values on an ancestor such as `body` do not apply, because the editor declares its own.
|
|
415
|
+
|
|
416
|
+
```css
|
|
417
|
+
.document-editor {
|
|
418
|
+
--nuvra-color-primary: #7c3aed;
|
|
419
|
+
--nuvra-color-primary-hover: #8b5cf6;
|
|
420
|
+
--nuvra-color-primary-border: #c4b5fd;
|
|
421
|
+
--nuvra-color-primary-muted: #ddd6fe;
|
|
422
|
+
--nuvra-color-primary-soft: #f5f3ff;
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
| Variable | Used for |
|
|
427
|
+
| ------------------------------- | ---------------------------------------------------- |
|
|
428
|
+
| `--nuvra-color-primary` | Active buttons, focus, primary buttons, selection |
|
|
429
|
+
| `--nuvra-color-primary-hover` | Hovered primary buttons |
|
|
430
|
+
| `--nuvra-color-primary-border` | Focused editor frame, disabled primary buttons |
|
|
431
|
+
| `--nuvra-color-primary-muted` | Table size preview, hovered button borders |
|
|
432
|
+
| `--nuvra-color-primary-soft` | Active button and menu item backgrounds |
|
|
433
|
+
| `--nuvra-color-on-primary` | Text on primary buttons |
|
|
434
|
+
| `--nuvra-color-danger` | Destructive actions, character limit reached |
|
|
435
|
+
| `--nuvra-color-danger-soft` | Hovered destructive actions |
|
|
436
|
+
| `--nuvra-text-strong` | Headings in forms |
|
|
437
|
+
| `--nuvra-text` | Regular text and icons |
|
|
438
|
+
| `--nuvra-text-muted` | Labels, captions, status bar |
|
|
439
|
+
| `--nuvra-text-placeholder` | Placeholders, shortcut hints |
|
|
440
|
+
| `--nuvra-text-disabled` | Disabled buttons |
|
|
441
|
+
| `--nuvra-border` | Editor frame, inputs |
|
|
442
|
+
| `--nuvra-border-hover` | Hovered inputs |
|
|
443
|
+
| `--nuvra-border-light` | Popovers and floating panels |
|
|
444
|
+
| `--nuvra-border-lighter` | Dividers |
|
|
445
|
+
| `--nuvra-fill` | Hovered buttons, segmented controls |
|
|
446
|
+
| `--nuvra-bg` | Toolbar, status bar, inputs |
|
|
447
|
+
| `--nuvra-bg-overlay` | Popovers, menus, find bar |
|
|
448
|
+
| `--nuvra-shadow` | Popovers and floating panels |
|
|
449
|
+
| `--nuvra-fullscreen-z-index` | Stacking order of the fullscreen editor (`2000`) |
|
|
450
|
+
|
|
451
|
+
A dark palette is applied when an ancestor (usually `<html>`) has the `dark` class or `data-theme="dark"`. To change
|
|
452
|
+
dark values separately, target `.dark .document-editor`.
|
|
453
|
+
|
|
454
|
+
To follow an Element Plus theme, map the variables to its own:
|
|
455
|
+
|
|
456
|
+
```css
|
|
457
|
+
.document-editor {
|
|
458
|
+
--nuvra-color-primary: var(--el-color-primary);
|
|
459
|
+
--nuvra-color-primary-soft: var(--el-color-primary-light-9);
|
|
460
|
+
--nuvra-text: var(--el-text-color-regular);
|
|
461
|
+
--nuvra-border: var(--el-border-color);
|
|
462
|
+
--nuvra-bg: var(--el-bg-color);
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Browser support
|
|
467
|
+
|
|
468
|
+
Popovers, menus and bubble toolbars use the [Popover API](https://developer.mozilla.org/docs/Web/API/Popover_API)
|
|
469
|
+
(Chrome/Edge 114+, Safari 17+, Firefox 125+), which keeps them above dialogs and the fullscreen editor. Older browsers
|
|
470
|
+
show them as fixed elements instead. Search highlighting uses the CSS Custom Highlight API.
|
|
471
|
+
|
|
472
|
+
## License
|
|
473
|
+
|
|
474
|
+
[MIT](./LICENSE). Icon shapes come from [Lucide](https://lucide.dev) (ISC).
|