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 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
- | `uploadImage` | `(file: File) => Promise<string>` | — | Uploads an image and resolves with its URL. |
123
- | `variables` | `TemplateVariable[]` | `[]` | Template variables the user can insert. |
124
-
125
- `Editor` accepts the same props except `v-model:page`, `v-model:comments`, `v-model:trackChanges`, `author`,
126
- `collaborators`, `defaultViewMode`, `height`, `ruler`, `slashCommands` and `title`.
127
-
128
- Numbers are pixels; strings are used as CSS lengths (`'100%'`, `'50vh'`).
129
-
130
- ### Events
131
-
132
- | Event | Payload | Description |
133
- | ----------------- | ----------------------------------- | -------------------------------------------------------------- |
134
- | `focus` | — | The document received focus. |
135
- | `blur` | — | The document lost focus; the model is up to date. |
136
- | `uploadError` | `error: unknown` | An image was rejected or its upload failed. |
137
- | `importError` | `error: unknown` | A Word file could not be read. |
138
- | `exportError` | `error: unknown` | The PDF could not be drawn; no file is downloaded. |
139
- | `selectionChange` | `selection: SelectionOffsets \| null` | The caret or selection moved; `null` when it left the document. |
140
-
141
- ### Exposed methods (`DocumentEditor` ref)
142
-
143
- | Method | Description |
144
- | ------------------------- | ---------------------------------------------------------------- |
145
- | `focus()` | Moves keyboard focus into the document. |
146
- | `getHTML()` | Returns the document HTML, including edits not yet in the model. |
147
- | `insertVariable(name)` | Inserts a template variable at the selection. |
148
- | `updateTableOfContents()` | Inserts or refreshes the table of contents. |
149
- | `importWord(file)` | Replaces the document with the content of a `.docx` file. |
150
- | `print()` | Opens the browser print dialog. |
151
- | `exportHtml()` | Downloads the document as an HTML page. |
152
- | `exportWord()` | Downloads the document as a Word file (`.docx`). |
153
- | `exportPdf()` | Downloads the document as a PDF drawn from its pages. |
154
- | `engine` | The editing engine (`DocumentEngine`), for advanced integrations. |
155
-
156
- ### Keyboard shortcuts
157
-
158
- `Ctrl/⌘+B`, `I`, `U` — bold, italic, underline · `Ctrl/⌘+Shift+H` — highlight · `Ctrl/⌘+Z`, `Ctrl/⌘+Shift+Z` — undo,
159
- redo · `Ctrl/⌘+F` — find · `Ctrl/⌘+H` — replace · `Ctrl/⌘+K` — link · `Ctrl/⌘+Alt+M` — comment · `Ctrl/⌘+P` — print.
160
-
161
- The full list, with Markdown-like input rules, is in the
162
- [keyboard shortcuts guide](https://nuvra-docs.vercel.app/docs/keyboard-shortcuts).
163
-
164
- ## Templates and signatures
165
-
166
- Pass `variables` to let users insert template variables with the toolbar's **{ }** menu or by typing `{{name}}`.
167
- They are saved as `<span data-variable="name">{{name}}</span>`; `fillTemplate` replaces them with escaped values,
168
- in the browser or on a Node server:
169
-
170
- ```ts
171
- import { type TemplateVariable, fillTemplate } from 'nuvra';
172
-
173
- const variables: TemplateVariable[] = [
174
- { name: 'full_name', label: 'Full name' },
175
- { name: 'letter_date', label: 'Letter date' }
176
- ];
177
-
178
- const letter = fillTemplate(template, { full_name: 'Aziz Karimov', letter_date: '14.09.2026' });
179
- ```
180
-
181
- The pen button inserts a signature block in the editor's language: a signature line, an “Approved” or “Agreed”
182
- block, or the signatures of both parties of a contract. It is a borderless table, so it prints and exports to Word
183
- without lines.
184
-
185
- The document button inserts ready-made templates (official letter, order, application, certificate, act), and the
186
- toolbar writes amounts in words (`15 000 000 (o‘n besh million)`), inserts long dates (`2026-yil 14-sentabr`) and
187
- converts Uzbek text between the Latin and Cyrillic alphabets. The same helpers are exported: `getDocumentTemplate`,
188
- `numberToWords`, `formatAmountInWords`, `parseAmount`, `formatLongDate` and `transliterate`.
189
-
190
- ### Filling a template as a form
191
-
192
- `DocumentForm` shows a saved template as it will be printed, with an input in place of every variable. Fields of the
193
- same variable share one value:
194
-
195
- ```vue
196
- <script setup lang="ts">
197
- import { ref } from 'vue';
198
- import { DocumentForm } from 'nuvra';
199
-
200
- const values = ref<Record<string, string>>({});
201
- const form = ref<InstanceType<typeof DocumentForm>>();
202
-
203
- const submit = () => {
204
- if (form.value?.validate().length) return; // empty fields are marked and focused
205
- const html = form.value?.getHTML(); // filled with fillTemplate
206
- };
207
- </script>
208
-
209
- <template>
210
- <DocumentForm ref="form" v-model="values" :template="template" :variables="variables" />
211
- </template>
212
- ```
213
-
214
- ## Word files and long documents
215
-
216
- "Download as Word (.docx)" in the "More" menu writes a real Office Open XML file with the page setup, headers and
217
- footers with page fields, the watermark, lists, tables with merged cells, images, footnotes and tracked changes as
218
- Word revisions. "Open Word file (.docx)" (or `importWord(file)`) loads one into the editor, footnotes and revisions
219
- included; a file that cannot be read emits `importError`. `buildDocx` and `readDocx` do the same in your own code. A
220
- document with several sections takes the page setup of its first section, and every further section starts with a
221
- section break; the content of a different first page header is not imported.
222
-
223
- ### PDF download
224
-
225
- "Download as PDF" in the "More" menu, or `exportPdf()`, saves a PDF without the print dialog. Every sheet is drawn into a
226
- picture (SVG `foreignObject` → canvas → JPEG) and written into the PDF, so it looks like the printout, but its text
227
- cannot be selected or searched. Images from servers without CORS are left out, and in the web view the editor switches to
228
- the page view for the moment of the export. A PDF that cannot be drawn emits `exportError`; printing with "Save as PDF"
229
- still gives a PDF with real text.
230
-
231
- ### Pages in different orientations
232
-
233
- "Section break: landscape pages" and "Section break: portrait pages" in the insert menu and the `/` menu (or
234
- `engine.insertSectionBreak('landscape')`) turn the pages after the break; paper size and margins stay, and the break
235
- starts a new page. It is saved as `<div data-type="section-break" data-orientation="landscape"></div>`. The page view
236
- draws sheets of different sizes and gives the blocks of a turned section their sheet's text width; printing uses a named
237
- `@page` for turned sheets, the PDF has turned pages, and the Word export writes every section as a Word section with its
238
- own orientation.
239
-
240
- ### Footnotes
241
-
242
- "Footnote" in the insert menu or the `/` menu adds a numbered reference and opens a small form for the note; clicking
243
- a reference edits or deletes it. A footnote is saved in the reference, `<sup data-footnote="note text">1</sup>`, and
244
- renumbered in document order. The page view draws the notes at the bottom of the sheet the reference is on, the web
245
- view after the document; printing follows the sheets, and the Word export writes Word footnotes. Notes are plain text.
246
- From code: `engine.insertFootnote(text)`, `setFootnoteText(element, text)`, `removeFootnote(element)`,
247
- `getFootnotes()`.
248
-
249
- For long documents the toolbar offers multilevel numbering (1., 1.1., 1.1.1., saved as `<ol data-numbering="legal">`),
250
- a table of contents of the headings with page numbers (saved as `<table data-type="toc">`, refreshed with
251
- `updateTableOfContents()`), and a navigation pane. `PageSettings` has `differentFirstPage` to hide the header, footer
252
- and page number on the first page, and `firstPageNumber` for the number printed on it. The navigation pane lists the
253
- headings or shows page thumbnails; clicking one scrolls to it.
254
-
255
- ## Comments, tracked changes and comparison
256
-
257
- Bind `v-model:comments` to turn comments on. The HTML keeps only the anchors, `<span data-comment="id">`; the
258
- comments are plain data you store next to the document:
259
-
260
- ```vue
261
- <script setup lang="ts">
262
- import { ref } from 'vue';
263
- import { DocumentEditor, type DocumentComment } from 'nuvra';
264
-
265
- const html = ref('');
266
- const comments = ref<DocumentComment[]>([]);
267
-
268
- const save = () =>
269
- fetch('/api/documents/42', {
270
- method: 'PUT',
271
- headers: { 'Content-Type': 'application/json' },
272
- body: JSON.stringify({ html: html.value, comments: comments.value })
273
- });
274
- </script>
275
-
276
- <template>
277
- <DocumentEditor v-model="html" v-model:comments="comments" author="Aziz Karimov" @blur="save" />
278
- </template>
279
- ```
280
-
281
- The comment and change tools are in the **Review** menu of the toolbar. Users select text and choose "Add comment"
282
- (`Ctrl/⌘+Alt+M`); the "Comments" panel replies, resolves, reopens and deletes them, and flags comments whose text was
283
- deleted.
284
-
285
- ### Tracked changes
286
-
287
- Bind `v-model:trackChanges` (or choose "Track changes" in the Review menu) to record typing, deleting, cut and paste as tracked changes
288
- by `author`. They are part of the HTML, `<ins data-change="id" data-author="…" data-time="…">` and `<del …>`, shown
289
- and printed green-underlined and red-struck. The "Changes" panel accepts or rejects them one by one or all at once;
290
- from code use `engine.getChanges()`, `engine.resolveChanges(accept, id?)` and `engine.selectChange(id)`. Formatting,
291
- block changes (headings, lists, tables), Enter and joining paragraphs are not tracked. The Word export writes them as
292
- revisions and the import reads Word revisions back.
293
-
294
- ```vue
295
- <DocumentEditor v-model="html" v-model:track-changes="tracking" author="Aziz Karimov" />
296
- ```
297
-
298
- `DocumentCompare` shows what changed between two versions: inserted words in green, deleted words struck through in
299
- red. Unchanged blocks stay as they are, changed paragraphs are compared word by word, and added or removed blocks are
300
- shown whole. `compareDocuments(before, after)` returns the same HTML and counts for your own view.
301
-
302
- ```vue
303
- <DocumentCompare :before="previousVersion" :after="html" :height="600" />
304
- ```
305
-
306
- ## Editing together
307
-
308
- The editor has hooks for several people on one document; the transport (WebSocket, WebRTC, …) is up to your app.
309
- `selectionChange` reports your caret or selection as character positions, `collaborators` draws other people's carets
310
- (with names) and selections, and a new `v-model` value from outside keeps your caret at the same character position.
311
- The engine also offers `getSelectionOffsets()`, `getOffsetRects(offsets)` and `setContent(html, { keepSelection })`;
312
- `collaboratorColor` gives the colour a collaborator is drawn in.
313
-
314
- ```vue
315
- <script setup lang="ts">
316
- import { ref } from 'vue';
317
- import { type Collaborator, DocumentEditor, type SelectionOffsets } from 'nuvra';
318
-
319
- const html = ref('');
320
- const collaborators = ref<Collaborator[]>([]); // filled from your socket messages
321
-
322
- const sendSelection = (selection: SelectionOffsets | null) =>
323
- socket.send(JSON.stringify({ type: 'selection', id: me.id, name: me.name, selection }));
324
- </script>
325
-
326
- <template>
327
- <DocumentEditor v-model="html" :collaborators="collaborators" @selection-change="sendSelection" />
328
- </template>
329
- ```
330
-
331
- This is not a CRDT: the document travels as a whole, so when two people type at the same time the document sent last
332
- wins, and because positions are character offsets, a remote edit before your caret shifts it. A library such as Yjs
333
- can carry the document and the selections (awareness), but edits are still not merged character by character. See the
334
- [guide](https://nuvra-docs.vercel.app/docs/collaboration).
335
-
336
- ## Commands and toolbar buttons
337
-
338
- Typing `/` at the start of a line or after a space opens a command menu: headings, lists, table, page and section breaks, footnote,
339
- table of contents, dates, signature blocks and your variables. `slashCommands` adds your own commands at the top, and the
340
- `toolbar` slot adds your own buttons:
341
-
342
- ```vue
343
- <script setup lang="ts">
344
- import { DocumentEditor, type SlashCommand } from 'nuvra';
345
-
346
- const slashCommands: SlashCommand[] = [
347
- { id: 'director', label: 'Director’s name', icon: 'pencil', run: engine => engine.insertText('A. Karimov') }
348
- ];
349
- </script>
350
-
351
- <template>
352
- <DocumentEditor v-model="html" :slash-commands="slashCommands">
353
- <template #toolbar="{ engine, disabled }">
354
- <button type="button" class="doc-tb-button" :disabled="disabled" @mousedown.prevent @click="engine.insertText('✓')">
355
- ✓
356
- </button>
357
- </template>
358
- </DocumentEditor>
359
- </template>
360
- ```
361
-
362
- ## Languages
363
-
364
- The interface ships in Uzbek (`uz`, the default), Uzbek Cyrillic (`uzCyrl`), English (`en`) and Russian (`ru`). The translations are part of
365
- the package and cannot be changed from outside; an app only picks the language.
366
-
367
- For one editor, pass the locale (or just its code) to the `locale` prop:
368
-
369
- ```vue
370
- <script setup lang="ts">
371
- import { DocumentEditor, ru } from 'nuvra';
372
- </script>
373
-
374
- <template>
375
- <DocumentEditor v-model="html" :locale="ru" />
376
- </template>
377
- ```
378
-
379
- For the whole app, call `setEditorLocale` once, for example in `main.ts`. It is reactive, so calling it again from a
380
- language switcher updates editors already on the page:
381
-
382
- ```ts
383
- import { en, setEditorLocale } from 'nuvra';
384
-
385
- setEditorLocale(en);
386
- ```
387
-
388
- The `locale` prop wins over `setEditorLocale`; without either the editor is in Uzbek. `editorLocales` lists every
389
- built-in locale with its name, for language pickers.
390
-
391
- ## Theming
392
-
393
- Colors come from CSS variables declared on `.document-editor` with zero specificity, so any rule that targets
394
- `.document-editor` overrides them, whatever order the stylesheets load in. Set them on the editor element itself:
395
- values on an ancestor such as `body` do not apply, because the editor declares its own.
396
-
397
- ```css
398
- .document-editor {
399
- --nuvra-color-primary: #7c3aed;
400
- --nuvra-color-primary-hover: #8b5cf6;
401
- --nuvra-color-primary-border: #c4b5fd;
402
- --nuvra-color-primary-muted: #ddd6fe;
403
- --nuvra-color-primary-soft: #f5f3ff;
404
- }
405
- ```
406
-
407
- | Variable | Used for |
408
- | ------------------------------- | ---------------------------------------------------- |
409
- | `--nuvra-color-primary` | Active buttons, focus, primary buttons, selection |
410
- | `--nuvra-color-primary-hover` | Hovered primary buttons |
411
- | `--nuvra-color-primary-border` | Focused editor frame, disabled primary buttons |
412
- | `--nuvra-color-primary-muted` | Table size preview, hovered button borders |
413
- | `--nuvra-color-primary-soft` | Active button and menu item backgrounds |
414
- | `--nuvra-color-on-primary` | Text on primary buttons |
415
- | `--nuvra-color-danger` | Destructive actions, character limit reached |
416
- | `--nuvra-color-danger-soft` | Hovered destructive actions |
417
- | `--nuvra-text-strong` | Headings in forms |
418
- | `--nuvra-text` | Regular text and icons |
419
- | `--nuvra-text-muted` | Labels, captions, status bar |
420
- | `--nuvra-text-placeholder` | Placeholders, shortcut hints |
421
- | `--nuvra-text-disabled` | Disabled buttons |
422
- | `--nuvra-border` | Editor frame, inputs |
423
- | `--nuvra-border-hover` | Hovered inputs |
424
- | `--nuvra-border-light` | Popovers and floating panels |
425
- | `--nuvra-border-lighter` | Dividers |
426
- | `--nuvra-fill` | Hovered buttons, segmented controls |
427
- | `--nuvra-bg` | Toolbar, status bar, inputs |
428
- | `--nuvra-bg-overlay` | Popovers, menus, find bar |
429
- | `--nuvra-shadow` | Popovers and floating panels |
430
- | `--nuvra-fullscreen-z-index` | Stacking order of the fullscreen editor (`2000`) |
431
-
432
- A dark palette is applied when an ancestor (usually `<html>`) has the `dark` class or `data-theme="dark"`. To change
433
- dark values separately, target `.dark .document-editor`.
434
-
435
- To follow an Element Plus theme, map the variables to its own:
436
-
437
- ```css
438
- .document-editor {
439
- --nuvra-color-primary: var(--el-color-primary);
440
- --nuvra-color-primary-soft: var(--el-color-primary-light-9);
441
- --nuvra-text: var(--el-text-color-regular);
442
- --nuvra-border: var(--el-border-color);
443
- --nuvra-bg: var(--el-bg-color);
444
- }
445
- ```
446
-
447
- ## Browser support
448
-
449
- Popovers, menus and bubble toolbars use the [Popover API](https://developer.mozilla.org/docs/Web/API/Popover_API)
450
- (Chrome/Edge 114+, Safari 17+, Firefox 125+), which keeps them above dialogs and the fullscreen editor. Older browsers
451
- show them as fixed elements instead. Search highlighting uses the CSS Custom Highlight API.
452
-
453
- ## License
454
-
455
- [MIT](./LICENSE). Icon shapes come from [Lucide](https://lucide.dev) (ISC).
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).