open-wysiwyg-editor 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alhassan-ahmed and open-wysiwyg-editor contributors
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,401 @@
1
+ # open-wysiwyg-editor
2
+
3
+ An accessible rich text editor that is RTL-first and safe under a strict CSP. It works in plain JavaScript and in any framework.
4
+
5
+ - **Accessible by design.** Built against WCAG 2.2 AA, ATAG 2.0 and the WAI-ARIA Authoring Practices. You can do everything from the keyboard and never get trapped. Screen readers hear announcements, and Windows High Contrast is supported.
6
+ - **Secure by default.** All HTML input passes through DOMPurify, URLs are checked against an allow-list, and Trusted Types are supported. It runs under `script-src 'self'; style-src 'self'` with no `unsafe-inline`.
7
+ - **Arabic and RTL first.** It ships with English and Arabic UIs, mirrors the layout, and lets each block carry its own direction.
8
+ - **Complete out of the box.** You get headings, lists, task lists, tables, images, links, code, text and highlight colors, alignment, find & replace, an HTML source view, word count and more.
9
+ - **Headless if you want it.** The same engine is available with no UI, so you can build your own interface on top.
10
+ - **MIT license.** There is no license key, no telemetry and no cloud dependency.
11
+
12
+ Built on [ProseMirror](https://prosemirror.net). Content is stored as HTML or JSON, and node and mark names match Tiptap's.
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install open-wysiwyg-editor
20
+ # or
21
+ pnpm add open-wysiwyg-editor
22
+ ```
23
+
24
+ Without a bundler, load the browser build from a CDN:
25
+
26
+ ```html
27
+ <link rel="stylesheet" href="https://unpkg.com/open-wysiwyg-editor/dist/style.min.css" />
28
+ <script src="https://unpkg.com/open-wysiwyg-editor/dist/open-wysiwyg-editor.global.js"></script>
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```html
34
+ <div id="editor"><p>Hello <strong>world</strong></p></div>
35
+ ```
36
+
37
+ ```js
38
+ import { createEditor } from "open-wysiwyg-editor";
39
+ import "open-wysiwyg-editor/style.css";
40
+
41
+ const editor = createEditor({ element: document.querySelector("#editor") });
42
+
43
+ editor.getHTML(); // "<p dir=\"auto\">Hello <strong>world</strong></p>"
44
+ ```
45
+
46
+ With the global build:
47
+
48
+ ```js
49
+ const editor = OpenWysiwygEditor.createEditor({ element: document.querySelector("#editor") });
50
+ ```
51
+
52
+ ### Plain HTML forms
53
+
54
+ If you mount the editor on a `<textarea>`, it hides the textarea and keeps its value in sync, so the form submits the HTML. It also uses the textarea's `<label>` as its accessible name.
55
+
56
+ ```html
57
+ <form method="post">
58
+ <label for="body">Article</label>
59
+ <textarea id="body" name="body"><p>Draft…</p></textarea>
60
+ <button>Save</button>
61
+ </form>
62
+ <script type="module">
63
+ import { createEditor } from "open-wysiwyg-editor";
64
+ createEditor({ element: document.querySelector("#body") });
65
+ </script>
66
+ ```
67
+
68
+ ### React / Next.js
69
+
70
+ Dedicated adapters for React, Vue, Svelte and Angular, and an `<owe-editor>` web component, are on the way. Until they ship, the core works in any framework. Mount it in an effect and destroy it on cleanup:
71
+
72
+ ```tsx
73
+ "use client"; // Next.js: the editor needs the DOM
74
+ import { useEffect, useRef } from "react";
75
+ import { createEditor, type Editor } from "open-wysiwyg-editor";
76
+ import "open-wysiwyg-editor/style.css";
77
+
78
+ export function RichText({ value, onChange }: { value: string; onChange: (html: string) => void }) {
79
+ const host = useRef<HTMLDivElement>(null);
80
+ const editor = useRef<Editor | null>(null);
81
+
82
+ useEffect(() => {
83
+ editor.current = createEditor({
84
+ element: host.current,
85
+ content: value,
86
+ onUpdate: (e) => onChange(e.getHTML()),
87
+ });
88
+ return () => editor.current?.destroy();
89
+ }, []);
90
+
91
+ return <div ref={host} />;
92
+ }
93
+ ```
94
+
95
+ ### Headless
96
+
97
+ Use the headless entry when you want the engine and every extension without the built-in UI:
98
+
99
+ ```js
100
+ import { createEditor, StarterKit } from "open-wysiwyg-editor/headless";
101
+
102
+ const editor = createEditor({ element, extensions: [StarterKit] });
103
+ boldButton.onclick = () => editor.chain().focus().toggleBold().run();
104
+ editor.subscribe(() => {
105
+ boldButton.setAttribute("aria-pressed", String(editor.isActive("bold")));
106
+ });
107
+ ```
108
+
109
+ From the main entry, `createEditor({ ui: false })` does the same thing.
110
+
111
+ ---
112
+
113
+ ## Options
114
+
115
+ ```ts
116
+ createEditor({
117
+ element, // container or <textarea>; omit to create it detached and append editor.root yourself
118
+ content, // HTML string (always sanitized) or ProseMirror JSON
119
+ extensions: [StarterKit],
120
+ editable: true,
121
+ autofocus: false, // true | "start" | "end"
122
+ placeholder: "Write something…",
123
+ ariaLabel, ariaLabelledBy, ariaDescribedBy,
124
+
125
+ language: "ar", // UI language; defaults to <html lang>
126
+ languages, // add or replace UI languages (see i18n)
127
+ labels: { bold: "Strong" },
128
+ dir: "rtl", // base direction of the content
129
+ contentLang: "ar-EG", // lang of the content (used for spellcheck and screen readers)
130
+
131
+ urlPolicy: { protocols: ["https", "mailto"], allowDataImages: false, allowRelative: true },
132
+ inputRules: true, // Markdown-style shortcuts: "# ", "* ", "1. ", "> ", "```", **bold**…
133
+
134
+ onCreate, onUpdate, onSelectionUpdate, onFocus, onBlur, onDestroy, onContentError,
135
+
136
+ ui: { // or `false` for headless
137
+ toolbar: ["bold", "italic", "|", "link", "textColor", "highlight", "|", "sourceCode"],
138
+ items: { /* custom buttons, see below */ },
139
+ statusbar: { elementPath: true, wordCount: true, characterCount: false }, // or false
140
+ theme: "auto", // "light" | "dark"
141
+ stickyToolbar: true,
142
+ shortcuts: { toolbar: "Alt-F10", help: "Alt-0", find: "Mod-f", link: "Mod-k" },
143
+ },
144
+ });
145
+ ```
146
+
147
+ ## Editor API
148
+
149
+ ```ts
150
+ editor.getHTML(); // clean, sanitized HTML
151
+ editor.getJSON(); // ProseMirror JSON
152
+ editor.getText({ blockSeparator: "\n\n" });
153
+ editor.setContent(htmlOrJson, { emitUpdate: false, addToHistory: false });
154
+ editor.clearContent();
155
+ editor.isEmpty; editor.isFocused; editor.isEditable;
156
+ editor.setEditable(false);
157
+ editor.setOptions({ placeholder: "…", dir: "rtl" });
158
+
159
+ editor.commands.toggleBold(); // run one command
160
+ editor.can().toggleBold(); // would it work here?
161
+ editor.chain().focus().toggleBold().setTextColor("#b3261e").run(); // one transaction, one undo step
162
+
163
+ editor.isActive("heading", { level: 2 });
164
+ editor.getAttributes("link"); // { href, target, title }
165
+
166
+ editor.on("update", () => {}); // returns an unsubscribe function
167
+ editor.subscribe(() => {}); // store-style: fires on every change (useSyncExternalStore-friendly)
168
+ editor.announce("Saved"); // send a message to screen readers
169
+ editor.destroy();
170
+ ```
171
+
172
+ The main entry also exports `getUI(editor)`, which returns `focusToolbar()`, `openDialog(kind)`, `openFind()`, `toggleSource()`, `isSourceMode()` and `update()`.
173
+
174
+ ### Commands
175
+
176
+ | Area | Commands |
177
+ | --- | --- |
178
+ | Marks | `toggleBold` `toggleItalic` `toggleUnderline` `toggleStrike` `toggleCode` `toggleSubscript` `toggleSuperscript` `unsetAllMarks` |
179
+ | Color | `setTextColor(color)` `unsetTextColor` `setHighlight(color?)` `unsetHighlight` `toggleHighlight(color?)` |
180
+ | Blocks | `setParagraph` `setHeading(level)` `toggleHeading(level)` `clearNodes` `toggleBlockquote` `toggleCodeBlock` `setHorizontalRule` `setHardBreak` |
181
+ | Lists | `toggleBulletList` `toggleOrderedList` `toggleTaskList` `sinkListItem` `liftListItem` |
182
+ | Links & images | `setLink({ href, text?, title?, newTab? })` `unsetLink` `setImage({ src, alt, caption? })` `updateImage` |
183
+ | Tables | `insertTable({ rows, cols, withHeaderRow, caption })` `addRowBefore/After` `addColumnBefore/After` `deleteRow` `deleteColumn` `deleteTable` `mergeCells` `splitCell` `toggleHeaderRow` `toggleHeaderColumn` `setTableCaption` |
184
+ | Layout | `setTextAlign("start" \| "center" \| "end" \| "justify")` `setTextDirection("ltr" \| "rtl" \| "auto")` and the matching `unset…` |
185
+ | Find | `setSearch({ query, caseSensitive, wholeWord })` `findNext` `findPrevious` `replaceCurrent(text)` `replaceAll(text)` `clearSearch` |
186
+ | General | `undo` `redo` `focus` `blur` `selectAll` |
187
+
188
+ Color commands accept hex, `rgb()`, `hsl()` or named colors. Anything else is refused and the command returns `false`.
189
+
190
+ ---
191
+
192
+ ## Features
193
+
194
+ ### Toolbar
195
+
196
+ The default toolbar contains:
197
+
198
+ `undo redo | blockType | bold italic underline strike code | textColor highlight | link | bulletList orderedList taskList | blockquote codeBlock | align direction | image table horizontalRule | removeFormat | find sourceCode help`
199
+
200
+ `subscript` and `superscript` are also available. A toolbar item only appears when its extension is enabled. On narrow screens and at 400% zoom the toolbar wraps onto more lines instead of scrolling sideways (WCAG 1.4.10).
201
+
202
+ To add your own button:
203
+
204
+ ```js
205
+ import { createEditor, DEFAULT_TOOLBAR } from "open-wysiwyg-editor";
206
+
207
+ createEditor({
208
+ element,
209
+ ui: {
210
+ toolbar: [...DEFAULT_TOOLBAR, "|", "timestamp"],
211
+ items: {
212
+ timestamp: {
213
+ label: "Insert date",
214
+ text: "Date", // or icon: "<built-in name>" | ["M4 12h16", …] (24×24 SVG paths)
215
+ // shortcut: "Mod-Shift-d" only shows the key in the tooltip; bind it with an extension keymap
216
+ run: (editor) => {
217
+ editor.view.dispatch(editor.state.tr.insertText(new Date().toLocaleDateString()));
218
+ editor.focus();
219
+ },
220
+ isEnabled: (editor) => editor.isEditable,
221
+ },
222
+ },
223
+ },
224
+ });
225
+ ```
226
+
227
+ ### Text and background color
228
+
229
+ The **Text color** and **Highlight** menu buttons open a palette of named swatches. Every built-in color has at least 4.5:1 contrast (WCAG AA): text colors against white, and highlights behind dark text. Each swatch is a menu radio item announced by name ("Dark red"), and the trigger button reports the current color.
230
+
231
+ **Custom…** opens a dialog with a color picker and a hex field. As you type, the dialog shows the contrast ratio live and warns when the color is below the AA minimum.
232
+
233
+ - The output is portable: `<span style="color: #b3261e">` and `<mark style="background-color: #fff2a8">`.
234
+ - Inside the editor, colors are applied through the CSSOM, never as `style` strings, so the live view stays clean under strict CSP.
235
+ - When you paste, noise colors are dropped: Word and Google Docs add black text, white or transparent backgrounds and `windowtext`. Real colors are kept.
236
+ - Set your own palettes with `StarterKit.configure({ textColor: { palette: [{ name: "Brand", value: "#0b57d0" }] } })`.
237
+ - The shortcut <kbd>Ctrl/⌘ + Shift + H</kbd> toggles the highlight.
238
+
239
+ The color helpers are exported: `normalizeColor`, `contrastRatio`, `luminance`, `parseColor`, `TEXT_PALETTE` and `HIGHLIGHT_PALETTE`.
240
+
241
+ ### HTML source view
242
+
243
+ The **Source code** button switches the editing area to a formatted, editable view of the HTML. When you switch back, the HTML is sanitized and parsed into the document again. Your edits become a single undo step. While the source view is open, the formatting buttons are disabled. It is also available from code: `getUI(editor).toggleSource()`.
244
+
245
+ ### General HTML support (opt-in)
246
+
247
+ By default the editor keeps only the markup its schema understands. This is the safest choice and gives the most predictable output. Add `HtmlSupport` to also keep other HTML the editor doesn't model, such as wrapper `<div>` and `<section>` elements, `<details>`, `<dl>`, `<abbr>`, `<span lang>`, classes, `data-*` attributes and ids:
248
+
249
+ ```js
250
+ import { createEditor, StarterKit, HtmlSupport } from "open-wysiwyg-editor";
251
+
252
+ createEditor({ element, extensions: [StarterKit, HtmlSupport] }); // "safe" preset
253
+ ```
254
+
255
+ You can also allow exactly what you want:
256
+
257
+ ```js
258
+ HtmlSupport.configure({
259
+ allow: [
260
+ { name: "section", classes: ["card", /^theme-/] },
261
+ { name: "p", attributes: ["data-*"], styles: ["text-indent"] },
262
+ ],
263
+ disallow: [{ name: "p", attributes: ["data-secret"] }], // disallow always wins
264
+ });
265
+ ```
266
+
267
+ Some things are always removed, whatever the rules say:
268
+
269
+ - event handlers
270
+ - `javascript:` and similar URLs
271
+ - `url()` and `expression()` in styles
272
+ - `contenteditable`, `tabindex` and `hidden`
273
+ - the editor's own classes
274
+ - ids that could clobber DOM globals
275
+
276
+ Attributes stored in JSON are validated again before output. Allowed `style` values are written to the output only. In the live editor they still need `style-src-attr 'unsafe-inline'`, so leave `styles` out if you need the editor to stay fully CSP-clean.
277
+
278
+ ### More
279
+
280
+ - **Tables.** Caption and header rows are on by default. Rows and columns can be inserted, deleted, merged and split, and header rows or columns can be toggled. Tab moves between cells.
281
+ - **Images.** The dialog asks for alt text, offers a "decorative image" option, and accepts an optional caption. Pasted or dropped files are uploaded only through your own `upload` function: `StarterKit.configure({ image: { upload: async (file) => url } })`. Without one, nothing is inserted, so you never get silent base64 data or surprise network calls.
282
+ - **Links.** The dialog has URL, text, title and "open in new tab" fields (new-tab links always get `rel="noopener noreferrer"`). Pasting a URL over selected text turns it into a link. Add rel values for user-generated content with `link: { rel: "nofollow ugc" }`.
283
+ - **Paste cleanup.** Pastes from Word, Google Docs and web pages are cleaned. Word's fake lists become real lists, and junk spans, comments and styles are stripped.
284
+ - **Find & replace.** Supports case-sensitive and whole-word search (word boundaries work in Arabic and CJK too), and announces the match count ("2 of 5 matches") and how many matches were replaced.
285
+ - **Status bar.** Shows an element path (a breadcrumb that selects the element you click) and word and character counts that segment words correctly in Arabic and CJK. Set a limit with `characterCount: { limit: 5000 }`.
286
+ - **Configure or remove anything.** For example: `StarterKit.configure({ table: false, heading: { levels: [2, 3] }, textAlign: { output: "class" } })`.
287
+
288
+ ---
289
+
290
+ ## Security
291
+
292
+ | Layer | What it does |
293
+ | --- | --- |
294
+ | Input | Every input path (initial content, `setContent`, paste, drop, source view) passes through DOMPurify. Script-capable elements are removed before parsing. |
295
+ | Model | Only schema-known content survives, unless `HtmlSupport` is added, and even then its blocklist applies. |
296
+ | Output | `getHTML()` is produced by a serializer that does not use the DOM. It validates tag and attribute names, escapes every value and checks every URL again. |
297
+ | URLs | Allowed by default: `http`, `https`, `mailto` and `tel`, plus relative URLs. `data:` images are off unless `urlPolicy.allowDataImages` is set. |
298
+ | CSP | Works under `script-src 'self'; style-src 'self'; require-trusted-types-for 'script'`. The live view never writes inline style strings. |
299
+ | Trusted Types | Adds one policy named `open-wysiwyg-editor` to your `trusted-types` directive, or pass your own with `setTrustedTypesPolicy()`. |
300
+
301
+ Still sanitize on the server. Never trust HTML that comes from the client. Report vulnerabilities through [SECURITY.md](https://github.com/alhassan73/open-wysiwyg-editor/blob/main/SECURITY.md).
302
+
303
+ Recommended CSP:
304
+
305
+ ```
306
+ Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';
307
+ img-src 'self' https: data:; require-trusted-types-for 'script';
308
+ trusted-types open-wysiwyg-editor
309
+ ```
310
+
311
+ If your published pages also forbid inline styles, use `textAlign: { output: "class" }` and the [content stylesheet](#styling).
312
+
313
+ ## Accessibility
314
+
315
+ - **Toolbar.** The toolbar follows the ARIA toolbar pattern with a single tab stop and a roving tabindex. Arrow keys, Home and End move between buttons, and the arrows follow RTL direction. Buttons announce their pressed or expanded state and their keyboard shortcuts.
316
+ - **Menus and dialogs.** Menus follow the APG menu-button pattern. Dialogs are native `<dialog>` elements with labelled fields, inline error messages and focus returned to where you were.
317
+ - **No keyboard trap.** In the text, Tab indents lists and moves between table cells. Press <kbd>Esc</kbd> and then <kbd>Tab</kbd> to leave the editor.
318
+ - **Announcements.** Live regions announce results such as "2 of 5 matches", "Link inserted" and "Some HTML isn't supported and was removed or simplified" (shown when the source view had to clean up HTML).
319
+ - **Visual accessibility.** Focus is always visible. The editor supports Windows High Contrast (`forced-colors`), honors `prefers-reduced-motion`, and offers a dark theme.
320
+ - **Help for authors (ATAG part B).** Images prompt for alt text, tables get headers and a caption by default, and color pickers report contrast. An accessibility checker that locates problems, explains them and repairs them is in progress.
321
+
322
+ | Shortcut | Action |
323
+ | --- | --- |
324
+ | <kbd>Alt + F10</kbd> | Move focus to the toolbar |
325
+ | <kbd>Esc</kbd> | Return from the toolbar to the text |
326
+ | <kbd>Alt + 0</kbd> | Open the list of keyboard shortcuts |
327
+ | <kbd>Ctrl/⌘ + K</kbd> | Insert or edit a link |
328
+ | <kbd>Ctrl/⌘ + F</kbd> | Find & replace |
329
+ | <kbd>Ctrl/⌘ + B / I / U</kbd> | Bold / italic / underline |
330
+ | <kbd>Ctrl/⌘ + Shift + H</kbd> | Highlight |
331
+ | <kbd>Ctrl/⌘ + Alt + 1…6</kbd> / <kbd>0</kbd> | Heading 1 to 6 / paragraph |
332
+ | <kbd>Ctrl/⌘ + Shift + 7 / 8 / 9</kbd> | Numbered, bulleted or task list |
333
+
334
+ The help dialog (<kbd>Alt + 0</kbd>) lists every shortcut for the current platform.
335
+
336
+ ## i18n and RTL
337
+
338
+ ```js
339
+ import { createEditor, arabicLabels } from "open-wysiwyg-editor";
340
+
341
+ createEditor({ element, language: "ar" }); // built in: en, ar
342
+ createEditor({
343
+ element,
344
+ language: "fr",
345
+ languages: [{ code: "fr", name: "Français", dir: "ltr", labels: { bold: "Gras" /* … */ } }],
346
+ });
347
+ ```
348
+
349
+ Untranslated labels fall back to English, and plurals use `Intl.PluralRules`. The UI mirrors itself in RTL languages. In the content, every block that has text gets `dir="auto"`, so Arabic and English paragraphs each display correctly in the same document. The direction button sets a block's direction explicitly.
350
+
351
+ ## Styling
352
+
353
+ | File | Use |
354
+ | --- | --- |
355
+ | `open-wysiwyg-editor/style.css` (`.min.css`) | The editor, its UI and its content styles |
356
+ | `open-wysiwyg-editor/content.css` (`.min.css`) | Only the content styles, for published pages: wrap saved HTML in `<div class="owe-content-root">` |
357
+
358
+ All rules are in `@layer owe`, so any of your own CSS outside a layer overrides them. To theme the editor, set the design tokens:
359
+
360
+ ```css
361
+ .owe {
362
+ --owe-accent: #0b57d0;
363
+ --owe-font: "IBM Plex Sans Arabic", system-ui, sans-serif;
364
+ --owe-radius: 10px;
365
+ }
366
+ ```
367
+
368
+ ## Extensions
369
+
370
+ Everything is built from extensions. You can write your own the same way:
371
+
372
+ ```ts
373
+ import { defineExtension } from "open-wysiwyg-editor";
374
+
375
+ const Timestamp = defineExtension({
376
+ name: "timestamp",
377
+ commands: () => ({
378
+ insertTimestamp: () => (state, dispatch) => {
379
+ dispatch?.(state.tr.insertText(new Date().toISOString()));
380
+ return true;
381
+ },
382
+ }),
383
+ keymap: ({ editor }) => ({ "Mod-Shift-d": () => editor.commands.insertTimestamp() }),
384
+ });
385
+
386
+ declare module "open-wysiwyg-editor" {
387
+ interface CommandMap { insertTimestamp: [] }
388
+ }
389
+
390
+ createEditor({ element, extensions: [StarterKit, Timestamp] });
391
+ ```
392
+
393
+ `defineExtension` also accepts `nodes`, `marks`, `globalAttributes`, `inputRules`, `plugins` (raw ProseMirror plugins), `onCreate` and `onDestroy`. Call `.configure(options)` on any extension to change its options.
394
+
395
+ ## Browser support
396
+
397
+ The editor targets current versions of Chrome, Edge, Firefox and Safari. Every change is tested end to end in Chromium, Firefox and WebKit, under a strict CSP with Trusted Types and with axe-core accessibility checks.
398
+
399
+ ## License
400
+
401
+ [MIT](https://github.com/alhassan73/open-wysiwyg-editor/blob/main/LICENSE)