open-edit 0.1.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/LICENSE +21 -0
- package/README.md +595 -0
- package/dist/core/commands.d.ts +33 -0
- package/dist/core/history.d.ts +18 -0
- package/dist/core/model.d.ts +25 -0
- package/dist/core/types.d.ts +229 -0
- package/dist/editor.d.ts +64 -0
- package/dist/index.d.ts +61 -0
- package/dist/io/deserializer.d.ts +3 -0
- package/dist/io/markdown.d.ts +3 -0
- package/dist/io/serializer.d.ts +4 -0
- package/dist/locales/de.d.ts +2 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/types.d.ts +77 -0
- package/dist/open-edit.cjs.js +783 -0
- package/dist/open-edit.cjs.js.map +1 -0
- package/dist/open-edit.esm.js +3333 -0
- package/dist/open-edit.esm.js.map +1 -0
- package/dist/open-edit.umd.js +783 -0
- package/dist/open-edit.umd.js.map +1 -0
- package/dist/plugins/ai.d.ts +16 -0
- package/dist/plugins/callout.d.ts +5 -0
- package/dist/plugins/emoji.d.ts +2 -0
- package/dist/plugins/highlight.d.ts +24 -0
- package/dist/plugins/slash-commands.d.ts +22 -0
- package/dist/plugins/template-tags.d.ts +2 -0
- package/dist/view/bubble-toolbar.d.ts +16 -0
- package/dist/view/code-lang-picker.d.ts +20 -0
- package/dist/view/icons.d.ts +1 -0
- package/dist/view/image-resize.d.ts +15 -0
- package/dist/view/renderer.d.ts +11 -0
- package/dist/view/selection.d.ts +17 -0
- package/dist/view/styles.d.ts +1 -0
- package/dist/view/toolbar.d.ts +31 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Nagel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# open-edit — Lightweight JavaScript WYSIWYG Editor
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/open-edit)
|
|
4
|
+
[](https://bundlephobia.com/package/open-edit)
|
|
5
|
+
[](#)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/cnagel08/open-edit/actions/workflows/ci.yml)
|
|
8
|
+
|
|
9
|
+
**A lightweight, open source rich text editor for the web — zero dependencies, ~31 kB gzipped.**
|
|
10
|
+
|
|
11
|
+
open-edit is a modern JavaScript WYSIWYG editor built on the `contenteditable` API. It works as a drop-in for any web project — plain HTML, React, Vue, or Svelte — and produces clean, semantic HTML5 output. No registration, no paid plans, MIT licensed.
|
|
12
|
+
|
|
13
|
+
[**Live Demo →**](https://cnagel08.github.io/open-edit/)
|
|
14
|
+
|
|
15
|
+
### Key Highlights
|
|
16
|
+
|
|
17
|
+
- **~31 kB** gzipped — fraction of TinyMCE or CKEditor
|
|
18
|
+
- **Zero runtime dependencies** — no bloat, no supply chain risk
|
|
19
|
+
- **Open source, MIT licensed** — free for personal and commercial use
|
|
20
|
+
- **Plugin system** — highlight, emoji, callout blocks, slash commands, AI assistant
|
|
21
|
+
- **Framework-agnostic** — React, Vue, Svelte, vanilla JS
|
|
22
|
+
- **Light / Dark / Auto theme** via CSS custom properties
|
|
23
|
+
- **Full TypeScript support**
|
|
24
|
+
|
|
25
|
+
<img src=".github/assets/screenshot.png" alt="open-edit lightweight javascript wysiwyg editor" width="900" />
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Why open-edit?
|
|
30
|
+
|
|
31
|
+
TinyMCE and CKEditor are powerful — but they ship hundreds of kilobytes, require registration or licensing, and pull in external dependencies. open-edit is different:
|
|
32
|
+
|
|
33
|
+
| | open-edit | TinyMCE | CKEditor 5 |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| Runtime dependencies | **0** | ~0 (self-hosted) | dozens |
|
|
36
|
+
| License | **MIT** | MIT / Commercial | GPL / Commercial |
|
|
37
|
+
| Self-hosted | **always** | yes | yes |
|
|
38
|
+
| Plugin system | **yes** | yes | yes |
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
**Via CDN**
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<div id="editor"></div>
|
|
49
|
+
<script src="https://unpkg.com/open-edit/dist/open-edit.umd.js"></script>
|
|
50
|
+
<script>
|
|
51
|
+
const editor = OpenEdit.create('#editor', {
|
|
52
|
+
content: '<p>Hello <strong>world</strong>!</p>',
|
|
53
|
+
placeholder: 'Start typing…',
|
|
54
|
+
theme: 'auto',
|
|
55
|
+
onChange: (html) => console.log(html),
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Via npm**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install open-edit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { OpenEdit } from 'open-edit';
|
|
68
|
+
|
|
69
|
+
const editor = OpenEdit.create('#editor', {
|
|
70
|
+
placeholder: 'Start typing…',
|
|
71
|
+
onChange: (html) => console.log(html),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
**Core**
|
|
80
|
+
|
|
81
|
+
| Feature | Details |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Text formatting | Bold, Italic, Underline, Strikethrough, Inline Code |
|
|
84
|
+
| Block elements | Paragraphs, Headings H1–H6, Lists, Blockquote, Code block, HR |
|
|
85
|
+
| Media | Images with drag-to-resize, upload hook |
|
|
86
|
+
| Editing | Text alignment, Links, Undo/Redo (50 steps) |
|
|
87
|
+
| I/O | HTML & Markdown import/export, smart clipboard paste |
|
|
88
|
+
| UI | Configurable toolbar, floating bubble toolbar, HTML source view, status bar |
|
|
89
|
+
| Theming | Light / Dark / Auto via CSS custom properties |
|
|
90
|
+
|
|
91
|
+
**Plugins** (all optional)
|
|
92
|
+
|
|
93
|
+
| Plugin | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `highlight` | Syntax highlighting for code blocks via highlight.js |
|
|
96
|
+
| `emoji` | Emoji picker in the toolbar |
|
|
97
|
+
| `templateTags` | Highlight and manage `{{variable}}` placeholders |
|
|
98
|
+
| `callout` | Info / Success / Warning / Danger callout blocks |
|
|
99
|
+
| `slashCommands` | Notion-style `/` command menu — 15+ block types |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## API Reference
|
|
104
|
+
|
|
105
|
+
### `OpenEdit.create(element, options?)`
|
|
106
|
+
|
|
107
|
+
Creates and mounts a new editor instance.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const editor = OpenEdit.create('#my-editor', options);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Parameters:**
|
|
114
|
+
|
|
115
|
+
| Parameter | Type | Description |
|
|
116
|
+
|-----------|------|-------------|
|
|
117
|
+
| `element` | `string \| HTMLElement` | CSS selector or DOM element |
|
|
118
|
+
| `options` | `EditorOptions` | Optional configuration |
|
|
119
|
+
|
|
120
|
+
**`EditorOptions`:**
|
|
121
|
+
|
|
122
|
+
| Option | Type | Default | Description |
|
|
123
|
+
|--------|------|---------|-------------|
|
|
124
|
+
| `content` | `string` | `''` | Initial HTML content |
|
|
125
|
+
| `placeholder` | `string` | `''` | Placeholder text when empty |
|
|
126
|
+
| `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Color theme |
|
|
127
|
+
| `readOnly` | `boolean` | `false` | Disable editing |
|
|
128
|
+
| `toolbar` | `ToolbarItemConfig[]` | full toolbar | Full custom toolbar (takes precedence over `toolbarItems`) |
|
|
129
|
+
| `toolbarItems` | `string[]` | all items | Filter default toolbar by item ID — see [Toolbar Items](#toolbar-items) |
|
|
130
|
+
| `statusBar` | `boolean \| StatusBarOptions` | `true` | Show/hide the status bar or individual parts |
|
|
131
|
+
| `onChange` | `(html: string) => void` | — | Called on every content change |
|
|
132
|
+
| `onImageUpload` | `(file: File) => Promise<string>` | — | Handle image uploads |
|
|
133
|
+
|
|
134
|
+
#### Toolbar Items
|
|
135
|
+
|
|
136
|
+
Available IDs for `toolbarItems`:
|
|
137
|
+
|
|
138
|
+
| ID | Element |
|
|
139
|
+
|----|---------|
|
|
140
|
+
| `undo`, `redo` | Undo / Redo buttons |
|
|
141
|
+
| `blockType` | Block format dropdown (Paragraph, H1–H4, …) |
|
|
142
|
+
| `bold`, `italic`, `underline`, `code` | Inline formatting |
|
|
143
|
+
| `alignLeft`, `alignCenter`, `alignRight`, `alignJustify` | Text alignment |
|
|
144
|
+
| `bulletList`, `orderedList` | Lists |
|
|
145
|
+
| `link`, `image`, `blockquote`, `hr` | Insert elements |
|
|
146
|
+
| `callout` | Insert callout button (Info variant) |
|
|
147
|
+
| `htmlToggle` | HTML source view button |
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// Minimal editor — only essential formatting
|
|
151
|
+
const editor = OpenEdit.create('#editor', {
|
|
152
|
+
toolbarItems: ['bold', 'italic', 'underline', 'link'],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Rich text editor without alignment and HTML toggle
|
|
156
|
+
const editor = OpenEdit.create('#editor', {
|
|
157
|
+
toolbarItems: ['undo', 'redo', 'blockType', 'bold', 'italic', 'underline',
|
|
158
|
+
'bulletList', 'orderedList', 'link', 'image'],
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
#### Status Bar Options
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// Hide the status bar entirely
|
|
166
|
+
OpenEdit.create('#editor', { statusBar: false });
|
|
167
|
+
|
|
168
|
+
// Show only word count, no HTML toggle
|
|
169
|
+
OpenEdit.create('#editor', {
|
|
170
|
+
statusBar: { wordCount: true, charCount: false, elementPath: false, htmlToggle: false },
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
| Option | Default | Description |
|
|
175
|
+
|--------|---------|-------------|
|
|
176
|
+
| `wordCount` | `true` | Word count |
|
|
177
|
+
| `charCount` | `true` | Character count |
|
|
178
|
+
| `elementPath` | `true` | Element path (e.g. `p › strong`) |
|
|
179
|
+
| `htmlToggle` | `true` | HTML source toggle button |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### Instance Methods
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// Content
|
|
187
|
+
editor.getHTML() // → string (clean HTML)
|
|
188
|
+
editor.setHTML(html) // set content from HTML
|
|
189
|
+
editor.getMarkdown() // → string (Markdown)
|
|
190
|
+
editor.setMarkdown(md) // set content from Markdown
|
|
191
|
+
editor.getDocument() // → EditorDocument (internal model)
|
|
192
|
+
|
|
193
|
+
// Editor state
|
|
194
|
+
editor.isEmpty() // → boolean
|
|
195
|
+
editor.isFocused() // → boolean
|
|
196
|
+
editor.isMarkActive(type) // → boolean ('bold' | 'italic' | …)
|
|
197
|
+
editor.getActiveBlockType()// → string ('paragraph' | 'heading' | …)
|
|
198
|
+
editor.getSelection() // → ModelSelection | null
|
|
199
|
+
|
|
200
|
+
// Focus
|
|
201
|
+
editor.focus()
|
|
202
|
+
editor.blur()
|
|
203
|
+
|
|
204
|
+
// Events
|
|
205
|
+
editor.on('change', (doc) => { })
|
|
206
|
+
editor.on('selectionchange', (sel) => { })
|
|
207
|
+
editor.on('focus', () => { })
|
|
208
|
+
editor.on('blur', () => { })
|
|
209
|
+
editor.off('change', listener)
|
|
210
|
+
|
|
211
|
+
// Commands (chainable)
|
|
212
|
+
editor.chain()
|
|
213
|
+
.toggleMark('bold')
|
|
214
|
+
.setBlock('heading', { level: 2 })
|
|
215
|
+
.setBlock('callout', { variant: 'warning' }) // insert/convert to callout
|
|
216
|
+
.setAlign('center')
|
|
217
|
+
.insertImage('https://example.com/img.png', 'alt text')
|
|
218
|
+
.insertHr()
|
|
219
|
+
.toggleList('bullet_list')
|
|
220
|
+
.undo()
|
|
221
|
+
.redo()
|
|
222
|
+
.run()
|
|
223
|
+
|
|
224
|
+
// Plugins
|
|
225
|
+
editor.use(myPlugin)
|
|
226
|
+
|
|
227
|
+
// Cleanup
|
|
228
|
+
editor.destroy()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Image Upload
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const editor = OpenEdit.create('#editor', {
|
|
237
|
+
onImageUpload: async (file) => {
|
|
238
|
+
const formData = new FormData();
|
|
239
|
+
formData.append('file', file);
|
|
240
|
+
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
241
|
+
const { url } = await res.json();
|
|
242
|
+
return url; // must return the public URL string
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Plugins
|
|
250
|
+
|
|
251
|
+
### Code Syntax Highlighting
|
|
252
|
+
|
|
253
|
+
Requires [highlight.js](https://highlightjs.org/) to be loaded.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { OpenEdit } from 'open-edit';
|
|
257
|
+
|
|
258
|
+
const editor = OpenEdit.create('#editor');
|
|
259
|
+
editor.use(OpenEdit.plugins.highlight());
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Emoji Picker
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
editor.use(OpenEdit.plugins.emoji());
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Template Variables
|
|
269
|
+
|
|
270
|
+
Highlight `{{variable}}` patterns in the editor content.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
editor.use(OpenEdit.plugins.templateTags({
|
|
274
|
+
variables: ['name', 'email', 'company'],
|
|
275
|
+
}));
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Callout Blocks
|
|
279
|
+
|
|
280
|
+
Adds styled callout/notice blocks in four variants: **Info**, **Success**, **Warning**, **Danger**.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
editor.use(OpenEdit.plugins.callout());
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Insert via toolbar:** Use the block-type dropdown or the ⓘ toolbar button (inserts Info callout).
|
|
287
|
+
|
|
288
|
+
**Insert via slash commands:** With the `slashCommands` plugin, type `/callout`, `/success`, `/warning`, or `/danger` to get a live-filtered menu. Without the slash commands plugin, the callout plugin also recognises these exact strings typed on a blank line followed by `Enter` (legacy behaviour).
|
|
289
|
+
|
|
290
|
+
| Slash query | Variant |
|
|
291
|
+
|---|---|
|
|
292
|
+
| `/callout`, `/info` | Info |
|
|
293
|
+
| `/success` | Success |
|
|
294
|
+
| `/warning` | Warning |
|
|
295
|
+
| `/danger` | Danger |
|
|
296
|
+
|
|
297
|
+
**Keyboard shortcut:** `Ctrl+Shift+I` inserts an Info callout.
|
|
298
|
+
|
|
299
|
+
**Change variant:** Click inside a callout and select a different variant from the block-type dropdown.
|
|
300
|
+
|
|
301
|
+
**Via the chain API:**
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
// Insert / convert current block to a callout
|
|
305
|
+
editor.chain().setBlock('callout', { variant: 'warning' }).run();
|
|
306
|
+
|
|
307
|
+
// Supported variants: 'info' | 'success' | 'warning' | 'danger'
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**HTML output:**
|
|
311
|
+
|
|
312
|
+
```html
|
|
313
|
+
<div class="oe-callout oe-callout-warning" data-callout-variant="warning">
|
|
314
|
+
Watch out for breaking changes!
|
|
315
|
+
</div>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Adding a new variant** requires changes in three places:
|
|
319
|
+
1. `CalloutVariant` type in `src/core/types.ts`
|
|
320
|
+
2. CSS block in `src/view/styles.ts`
|
|
321
|
+
3. Locale strings in `src/locales/types.ts`, `en.ts`, `de.ts` and a new option in `src/view/toolbar.ts`
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
### Slash Commands
|
|
326
|
+
|
|
327
|
+
A Notion-style `/` command menu that lets users quickly insert any block type by typing a slash at the start of a line.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
editor.use(OpenEdit.plugins.slashCommands());
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**How it works:** Type `/` at the start of any block. A floating dropdown appears, filtered in real-time as you continue typing. Navigate with `↑`/`↓`, confirm with `Enter` or `Tab`, dismiss with `Escape`.
|
|
334
|
+
|
|
335
|
+
**Built-in commands (15+):**
|
|
336
|
+
|
|
337
|
+
| Query examples | Inserts |
|
|
338
|
+
|---|---|
|
|
339
|
+
| `/p`, `/paragraph` | Paragraph |
|
|
340
|
+
| `/h1` – `/h6`, `/heading1` | Heading 1–6 |
|
|
341
|
+
| `/quote`, `/bq` | Blockquote |
|
|
342
|
+
| `/code`, `/codeblock` | Code Block |
|
|
343
|
+
| `/bullet`, `/ul` | Bullet List |
|
|
344
|
+
| `/numbered`, `/ol` | Numbered List |
|
|
345
|
+
| `/hr`, `/divider`, `/---` | Horizontal Rule |
|
|
346
|
+
| `/callout`, `/info` | Callout: Info |
|
|
347
|
+
| `/success` | Callout: Success |
|
|
348
|
+
| `/warning` | Callout: Warning |
|
|
349
|
+
| `/danger`, `/error` | Callout: Danger |
|
|
350
|
+
|
|
351
|
+
**Adding extra commands:**
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
editor.use(OpenEdit.plugins.slashCommands({
|
|
355
|
+
extraCommands: [
|
|
356
|
+
{
|
|
357
|
+
id: 'my-block',
|
|
358
|
+
title: 'My Custom Block',
|
|
359
|
+
description: 'Inserts something special',
|
|
360
|
+
icon: '✦', // SVG string or text
|
|
361
|
+
keywords: ['my', 'custom', 'special'],
|
|
362
|
+
execute: (editor) => editor.chain().setBlock('paragraph').run(),
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
}));
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**Replacing the command list entirely:**
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
editor.use(OpenEdit.plugins.slashCommands({ commands: myCommands }));
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The `SlashCommand` and `SlashCommandsOptions` types are exported for TypeScript consumers.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
### Writing a Custom Plugin
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import type { EditorPlugin } from 'open-edit';
|
|
382
|
+
|
|
383
|
+
const wordCountPlugin: EditorPlugin = {
|
|
384
|
+
name: 'word-count',
|
|
385
|
+
onInit(editor) {
|
|
386
|
+
editor.on('change', () => {
|
|
387
|
+
const text = editor.getHTML().replace(/<[^>]+>/g, ' ');
|
|
388
|
+
const words = text.trim().split(/\s+/).filter(Boolean).length;
|
|
389
|
+
console.log(`Word count: ${words}`);
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
onDestroy(editor) {
|
|
393
|
+
editor.off('change', () => {});
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
editor.use(wordCountPlugin);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Markdown Support
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
// Export as Markdown
|
|
406
|
+
const md = editor.getMarkdown();
|
|
407
|
+
|
|
408
|
+
// Import from Markdown
|
|
409
|
+
editor.setMarkdown('# Hello\n\nThis is **bold** text.');
|
|
410
|
+
|
|
411
|
+
// Utility functions
|
|
412
|
+
import { serializeToMarkdown, deserializeMarkdown } from 'open-edit';
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Internationalization (i18n)
|
|
418
|
+
|
|
419
|
+
OpenEdit ships with **English** and **German**. The UI language is detected automatically from the browser — no configuration needed.
|
|
420
|
+
|
|
421
|
+
### Auto-detection (default)
|
|
422
|
+
|
|
423
|
+
OpenEdit reads `navigator.language` and picks the matching built-in locale automatically. If the browser language is not supported yet, it falls back to English.
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
// Browser set to German → German UI automatically
|
|
427
|
+
// Browser set to French → English fallback
|
|
428
|
+
const editor = OpenEdit.create('#editor');
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Explicit locale
|
|
432
|
+
|
|
433
|
+
Override the auto-detection by passing a locale object:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { OpenEdit, de } from 'open-edit';
|
|
437
|
+
|
|
438
|
+
const editor = OpenEdit.create('#editor', {
|
|
439
|
+
locale: de, // always German, regardless of browser language
|
|
440
|
+
});
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Via CDN / `test.html` (UMD build — all locales are included):
|
|
444
|
+
```js
|
|
445
|
+
const editor = OpenEdit.create('#editor', {
|
|
446
|
+
locale: OpenEdit.locales.de,
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Partial override
|
|
451
|
+
|
|
452
|
+
Override only individual strings, keep everything else as-is:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
const editor = OpenEdit.create('#editor', {
|
|
456
|
+
locale: {
|
|
457
|
+
statusBar: { words: 'Mots', characters: 'Caractères', htmlSource: 'HTML' },
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Available locales
|
|
463
|
+
|
|
464
|
+
| Import | Language | Auto-detected for |
|
|
465
|
+
|--------|----------|-------------------|
|
|
466
|
+
| `en` | English | default / fallback |
|
|
467
|
+
| `de` | German | `de`, `de-AT`, `de-CH`, … |
|
|
468
|
+
|
|
469
|
+
### Adding a new language
|
|
470
|
+
|
|
471
|
+
1. Create `src/locales/fr.ts` implementing `EditorLocale`
|
|
472
|
+
2. Add it to `BUILT_IN_LOCALES` in `src/editor.ts` for auto-detection
|
|
473
|
+
3. Export it from `src/index.ts`
|
|
474
|
+
|
|
475
|
+
TypeScript enforces completeness — a compile error is thrown for any missing key, so incomplete translations cannot be published.
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
import type { EditorLocale } from 'open-edit';
|
|
479
|
+
|
|
480
|
+
export const fr: EditorLocale = {
|
|
481
|
+
toolbar: {
|
|
482
|
+
undo: 'Annuler (Ctrl+Z)',
|
|
483
|
+
// ... all keys required — TypeScript will tell you which ones are missing
|
|
484
|
+
},
|
|
485
|
+
// ...
|
|
486
|
+
};
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Theming
|
|
492
|
+
|
|
493
|
+
OpenEdit uses CSS custom properties. Override them to match your brand:
|
|
494
|
+
|
|
495
|
+
```css
|
|
496
|
+
:root {
|
|
497
|
+
--oe-primary: #2563eb;
|
|
498
|
+
--oe-bg: #ffffff;
|
|
499
|
+
--oe-bg-toolbar: #f8fafc;
|
|
500
|
+
--oe-border: #e2e8f0;
|
|
501
|
+
--oe-text: #1e293b;
|
|
502
|
+
--oe-radius: 8px;
|
|
503
|
+
--oe-font: system-ui, sans-serif;
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
For dark mode, set `theme: 'dark'` or use `theme: 'auto'` to follow the OS preference.
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Framework Integration
|
|
512
|
+
|
|
513
|
+
### React
|
|
514
|
+
|
|
515
|
+
```tsx
|
|
516
|
+
import { useEffect, useRef } from 'react';
|
|
517
|
+
import { OpenEdit, EditorInterface } from 'open-edit';
|
|
518
|
+
|
|
519
|
+
export function Editor({ onChange }: { onChange: (html: string) => void }) {
|
|
520
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
521
|
+
const editorRef = useRef<EditorInterface | null>(null);
|
|
522
|
+
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
if (!ref.current) return;
|
|
525
|
+
editorRef.current = OpenEdit.create(ref.current, { onChange });
|
|
526
|
+
return () => editorRef.current?.destroy();
|
|
527
|
+
}, []);
|
|
528
|
+
|
|
529
|
+
return <div ref={ref} />;
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Vue 3
|
|
534
|
+
|
|
535
|
+
```vue
|
|
536
|
+
<script setup lang="ts">
|
|
537
|
+
import { onMounted, onUnmounted, ref } from 'vue';
|
|
538
|
+
import { OpenEdit, EditorInterface } from 'open-edit';
|
|
539
|
+
|
|
540
|
+
const container = ref<HTMLDivElement>();
|
|
541
|
+
let editor: EditorInterface;
|
|
542
|
+
|
|
543
|
+
onMounted(() => {
|
|
544
|
+
editor = OpenEdit.create(container.value!, {
|
|
545
|
+
onChange: (html) => emit('update:modelValue', html),
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
onUnmounted(() => editor?.destroy());
|
|
550
|
+
</script>
|
|
551
|
+
|
|
552
|
+
<template>
|
|
553
|
+
<div ref="container" />
|
|
554
|
+
</template>
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## Local Development
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
git clone https://github.com/cnagel08/open-edit.git
|
|
563
|
+
cd open-edit
|
|
564
|
+
npm install
|
|
565
|
+
npm run dev # build in watch mode
|
|
566
|
+
# open test.html in your browser to test the editor
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
npm run build # production build → dist/
|
|
571
|
+
npm run lint # ESLint
|
|
572
|
+
npm test # minimal regression suite
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Repository Structure Notes
|
|
578
|
+
|
|
579
|
+
- `src/` is the OpenEdit library source and the only package code shipped to npm.
|
|
580
|
+
- `d35cb609-ad74-48a3-a8b4-26879657ff81/` is a standalone playground/reference app kept for comparison and experiments.
|
|
581
|
+
- `designs/preview/` is design/prototype material and not part of the published library bundle.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Contributing
|
|
586
|
+
|
|
587
|
+
Contributions are welcome! Please read [CONTRIBUTING.md](.github/CONTRIBUTING.md) before opening a pull request.
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## License
|
|
592
|
+
|
|
593
|
+
[MIT](LICENSE) © 2026 Christian Nagel
|
|
594
|
+
|
|
595
|
+
Free for personal and commercial use.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EditorDocument, MarkType } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Re-sync the document model from the current DOM state.
|
|
4
|
+
* Called after browser-native text input events.
|
|
5
|
+
*/
|
|
6
|
+
export declare function syncFromDOM(root: HTMLElement): EditorDocument;
|
|
7
|
+
export declare function execInlineCommand(command: string, value?: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Set the block type of the block containing the cursor.
|
|
10
|
+
* Returns the updated document.
|
|
11
|
+
*/
|
|
12
|
+
export declare function setBlockType(doc: EditorDocument, blockIndex: number, newType: string, attrs?: Record<string, unknown>): EditorDocument;
|
|
13
|
+
/**
|
|
14
|
+
* Toggle a list type on the block at blockIndex.
|
|
15
|
+
* If already that list type, convert back to paragraph.
|
|
16
|
+
*/
|
|
17
|
+
export declare function toggleList(doc: EditorDocument, blockIndex: number, listType: 'bullet_list' | 'ordered_list'): EditorDocument;
|
|
18
|
+
/**
|
|
19
|
+
* Set text alignment on the block at blockIndex.
|
|
20
|
+
*/
|
|
21
|
+
export declare function setAlignment(doc: EditorDocument, blockIndex: number, align: 'left' | 'center' | 'right' | 'justify'): EditorDocument;
|
|
22
|
+
/**
|
|
23
|
+
* Insert an image node after the block at blockIndex.
|
|
24
|
+
*/
|
|
25
|
+
export declare function insertImage(doc: EditorDocument, blockIndex: number, src: string, alt?: string): EditorDocument;
|
|
26
|
+
/**
|
|
27
|
+
* Insert a horizontal rule after the block at blockIndex.
|
|
28
|
+
*/
|
|
29
|
+
export declare function insertHr(doc: EditorDocument, blockIndex: number): EditorDocument;
|
|
30
|
+
export declare function isMarkActiveInDOM(markType: MarkType): boolean;
|
|
31
|
+
export declare function getBlockTypeFromDOM(root: HTMLElement): string;
|
|
32
|
+
export declare function getAlignmentFromDOM(root: HTMLElement): string;
|
|
33
|
+
export declare function insertLink(href: string, target: string): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EditorDocument } from './types.js';
|
|
2
|
+
export declare class History {
|
|
3
|
+
private undoStack;
|
|
4
|
+
private redoStack;
|
|
5
|
+
private _paused;
|
|
6
|
+
/** Push a snapshot onto the undo stack (clears redo) */
|
|
7
|
+
push(doc: EditorDocument): void;
|
|
8
|
+
/** Undo: returns the previous state (or null if nothing to undo) */
|
|
9
|
+
undo(current: EditorDocument): EditorDocument | null;
|
|
10
|
+
/** Redo: returns the next state (or null if nothing to redo) */
|
|
11
|
+
redo(current: EditorDocument): EditorDocument | null;
|
|
12
|
+
canUndo(): boolean;
|
|
13
|
+
canRedo(): boolean;
|
|
14
|
+
/** Pause recording (e.g. during undo/redo itself) */
|
|
15
|
+
pause(): void;
|
|
16
|
+
resume(): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EditorDocument, BlockNode, InlineNode, TextNode, Mark, MarkType, ParagraphNode, HeadingNode, ListItemNode, BulletListNode, OrderedListNode, BlockquoteNode, CodeBlockNode, CalloutNode, CalloutVariant } from './types.js';
|
|
2
|
+
export declare function createDocument(children?: BlockNode[]): EditorDocument;
|
|
3
|
+
export declare function createParagraph(text?: string): ParagraphNode;
|
|
4
|
+
export declare function createHeading(level: 1 | 2 | 3 | 4 | 5 | 6, text?: string): HeadingNode;
|
|
5
|
+
export declare function createText(text: string, marks?: Mark[]): TextNode;
|
|
6
|
+
export declare function createListItem(text?: string): ListItemNode;
|
|
7
|
+
export declare function createBulletList(items?: string[]): BulletListNode;
|
|
8
|
+
export declare function createOrderedList(items?: string[]): OrderedListNode;
|
|
9
|
+
export declare function createBlockquote(text?: string): BlockquoteNode;
|
|
10
|
+
export declare function createCodeBlock(code?: string, lang?: string): CodeBlockNode;
|
|
11
|
+
export declare function createCallout(text?: string, variant?: CalloutVariant): CalloutNode;
|
|
12
|
+
export declare function hasMark(node: TextNode, type: MarkType): boolean;
|
|
13
|
+
export declare function addMark(node: TextNode, mark: Mark): TextNode;
|
|
14
|
+
export declare function removeMark(node: TextNode, type: MarkType): TextNode;
|
|
15
|
+
export declare function toggleMark(node: TextNode, mark: Mark): TextNode;
|
|
16
|
+
/** Get plain text from inline nodes */
|
|
17
|
+
export declare function getPlainText(inlines: InlineNode[]): string;
|
|
18
|
+
/** Split inline content at a character offset, returns [before, after] */
|
|
19
|
+
export declare function splitInlinesAt(inlines: InlineNode[], offset: number): [InlineNode[], InlineNode[]];
|
|
20
|
+
/** Merge adjacent text nodes with identical marks */
|
|
21
|
+
export declare function normalizeInlines(inlines: InlineNode[]): InlineNode[];
|
|
22
|
+
/** Get total character length of a block's inline content */
|
|
23
|
+
export declare function blockLength(block: BlockNode): number;
|
|
24
|
+
/** Create a fresh empty document */
|
|
25
|
+
export declare function emptyDocument(): EditorDocument;
|