@dojo-ng/rich-text 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/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @dojo-ng/rich-text
2
+
3
+ `<dj-rich-text>` — A form-associated WYSIWYG editor built on the Lexical core.
4
+
5
+ Part of [Dojo NG](../../README.md), a framework-agnostic web component library built on Lit. BSD-3-Clause.
6
+
7
+ A form-associated WYSIWYG editor built on the Lexical core. The editable region renders in LIGHT DOM (Lexical's selection handling is not reliable inside a shadow root yet), so this component overrides `createRenderRoot`; theming still works because `--dj-*` tokens cascade in light DOM. The editor is a PLUGIN HOST: bold/italic/underline and undo/redo ship as the default plugin set (`default-plugins.ts`) and flow through the same {@link RichTextPlugin} API third-party plugins use. Foundational behavior (`registerRichText`, value sync, root-element setup) stays as core. Toolbar controls, node registration, and output formats all come from plugins. Constraint: Lexical needs node classes at creation, so a `plugins` change after creation rebuilds the editor (serialize → recreate → deserialize). Value is HTML by default; the `format` property selects an alternate serializer contributed by a plugin. Event: `dj-change`.
8
+
9
+ > Pasted HTML is sanitized against an allowlist by default (scripts, styles, event handlers, inline styles, and unsafe `javascript:`/`data:` URLs are stripped; unknown tags are unwrapped, keeping their text) — a security and consistency hook, not a nicety. Set `sanitizePaste = false` in JS to turn it off, or supply your own `pasteSanitizer(html) => html`. Plain-text pastes bypass it. The exported `sanitizeHtml(html)` is the default and can be reused. Formatting, headings/lists/links, and other node types come from plugins; setting `plugins` replaces the default set, so spread `...defaultPlugins` to keep the basics.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @dojo-ng/rich-text
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Import the package to register the custom element, then use the tag.
20
+
21
+ Lexical-based; set and read `value` (HTML).
22
+
23
+ ```html
24
+ <dj-rich-text id="rt" label="Description"></dj-rich-text>
25
+ <script type="module">
26
+ import "@dojo-ng/rich-text";
27
+ document.getElementById("rt").value = "<p>Hello <strong>world</strong></p>";
28
+ </script>
29
+ ```
30
+
31
+ ## Properties
32
+
33
+ `↻` marks an attribute reflected to the DOM; a dash means the property is set in JavaScript only.
34
+
35
+ | Property | Attribute | Type | Default |
36
+ |---|---|---|---|
37
+ | `value` | value | `string` | `""` |
38
+ | `name` | name ↻ | `string` | — |
39
+ | `label` | label | `string` | — |
40
+ | `placeholder` | placeholder | `string` | `""` |
41
+ | `disabled` | disabled ↻ | `boolean` | `false` |
42
+ | `plugins` | — | `RichTextPlugin[]` | `[]` |
43
+ | `format` | format ↻ | `string` | `"html"` |
44
+ | `sanitizePaste` | — | `boolean` | `true` |
45
+ | `pasteSanitizer` | — | `(html: string) => string` | — |
46
+
47
+ **Events:** `dj-change`
48
+
49
+ **Methods:** `checkValidity(): boolean`, `focus(o: FocusOptions)`, `setHtml(htmlString: string)` (Replace the document with the given HTML (regardless of the active `format`).)
50
+
51
+ ## Theming
52
+
53
+ Styled with Dojo NG `--dj-*` design tokens and exposes `::part()` hooks for targeted overrides.
54
+
55
+ ## Accessibility and i18n
56
+
57
+ Follows the project's WCAG 2.2 AA and localization conventions.
58
+
59
+ ## More
60
+
61
+ Live, interactive examples are in the playground (`playground/index.html`). For the full API reference, theming, accessibility, and localization guides, see the [Dojo NG documentation](../../README.md).
@@ -0,0 +1,8 @@
1
+ import { type RichTextPlugin } from "./plugin.js";
2
+ /** Bold / italic / underline. Commands are handled by the component's core `registerRichText`; this
3
+ * plugin only contributes toolbar controls and reflects active state. */
4
+ export declare const formattingPlugin: RichTextPlugin;
5
+ /** Undo / redo. `setup` installs Lexical's history listener and returns its disposer. */
6
+ export declare const historyPlugin: RichTextPlugin;
7
+ /** The default plugin set applied when the component's `plugins` property is left empty. */
8
+ export declare const defaultPlugins: RichTextPlugin[];
@@ -0,0 +1,54 @@
1
+ import { html } from "lit";
2
+ import { FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, } from "lexical";
3
+ import { registerHistory, createEmptyHistoryState } from "@lexical/history";
4
+ import { defineRichTextPlugin } from "./plugin.js";
5
+ /**
6
+ * Default plugin set for `<dj-rich-text>`: the bold/italic/underline formatting controls and the
7
+ * undo/redo history. These ship as plugins (not hard-coded into the component) so the basics flow
8
+ * through the same API third-party plugins use, and so a consumer can replace or omit them by
9
+ * passing their own `plugins` array. Foundational rich-text behavior (`registerRichText`, the
10
+ * value-sync listener, root-element setup) stays in the component as core, not as a plugin.
11
+ */
12
+ const glyph = (text, style) => html `<span style=${style}>${text}</span>`;
13
+ /** Bold / italic / underline. Commands are handled by the component's core `registerRichText`; this
14
+ * plugin only contributes toolbar controls and reflects active state. */
15
+ export const formattingPlugin = defineRichTextPlugin({
16
+ name: "formatting",
17
+ toolbar: [
18
+ {
19
+ id: "bold", group: "format", order: 1, label: "Bold",
20
+ icon: glyph("B", "font-weight:700"),
21
+ isActive: (ctx) => ctx.activeFormats().has("bold"),
22
+ run: (ctx) => ctx.command(FORMAT_TEXT_COMMAND, "bold"),
23
+ },
24
+ {
25
+ id: "italic", group: "format", order: 2, label: "Italic",
26
+ icon: glyph("I", "font-style:italic"),
27
+ isActive: (ctx) => ctx.activeFormats().has("italic"),
28
+ run: (ctx) => ctx.command(FORMAT_TEXT_COMMAND, "italic"),
29
+ },
30
+ {
31
+ id: "underline", group: "format", order: 3, label: "Underline",
32
+ icon: glyph("U", "text-decoration:underline"),
33
+ isActive: (ctx) => ctx.activeFormats().has("underline"),
34
+ run: (ctx) => ctx.command(FORMAT_TEXT_COMMAND, "underline"),
35
+ },
36
+ ],
37
+ });
38
+ /** Undo / redo. `setup` installs Lexical's history listener and returns its disposer. */
39
+ export const historyPlugin = defineRichTextPlugin({
40
+ name: "history",
41
+ setup: (ctx) => registerHistory(ctx.editor, createEmptyHistoryState(), 1000),
42
+ toolbar: [
43
+ {
44
+ id: "undo", group: "history", order: 1, label: "Undo", icon: "↶",
45
+ run: (ctx) => ctx.command(UNDO_COMMAND, undefined),
46
+ },
47
+ {
48
+ id: "redo", group: "history", order: 2, label: "Redo", icon: "↷",
49
+ run: (ctx) => ctx.command(REDO_COMMAND, undefined),
50
+ },
51
+ ],
52
+ });
53
+ /** The default plugin set applied when the component's `plugins` property is left empty. */
54
+ export const defaultPlugins = [formattingPlugin, historyPlugin];
@@ -0,0 +1,67 @@
1
+ import { type TemplateResult } from "lit";
2
+ import DojoElement, { DojoFormControl } from "@dojo-ng/dojo-element";
3
+ import "@dojo-ng/button";
4
+ import { type LexicalEditor } from "lexical";
5
+ import { type RichTextPlugin } from "./plugin.js";
6
+ declare const DjRichText_base: typeof DojoElement & (abstract new (...args: any[]) => import("@dojo-ng/dojo-element").FormControlMixinInterface);
7
+ /**
8
+ * `<dj-rich-text>` — a form-associated WYSIWYG editor built on the Lexical core. The editable
9
+ * region renders in LIGHT DOM (Lexical's selection handling is not reliable inside a shadow root
10
+ * yet), so this component overrides `createRenderRoot`; theming still works because `--dj-*` tokens
11
+ * cascade in light DOM.
12
+ *
13
+ * The editor is a PLUGIN HOST: bold/italic/underline and undo/redo ship as the default plugin set
14
+ * (`default-plugins.ts`) and flow through the same {@link RichTextPlugin} API third-party plugins
15
+ * use. Foundational behavior (`registerRichText`, value sync, root-element setup) stays as core.
16
+ * Toolbar controls, node registration, and output formats all come from plugins.
17
+ *
18
+ * Constraint: Lexical needs node classes at creation, so a `plugins` change after creation rebuilds
19
+ * the editor (serialize → recreate → deserialize). Value is HTML by default; the `format` property
20
+ * selects an alternate serializer contributed by a plugin. Event: `dj-change`.
21
+ */
22
+ export declare class DjRichText extends DjRichText_base implements Partial<DojoFormControl> {
23
+ #private;
24
+ static version: string;
25
+ static focusable: boolean;
26
+ static formAssociated: boolean;
27
+ protected createRenderRoot(): this;
28
+ private editable;
29
+ value: string;
30
+ name?: string;
31
+ label?: string;
32
+ placeholder: string;
33
+ disabled: boolean;
34
+ /** Plugin set. Set in JavaScript (rich data). The default set is used when this is empty. */
35
+ plugins: RichTextPlugin[];
36
+ /** Output format; selects which serializer the value getter/setter uses. `"html"` is built in. */
37
+ format: string;
38
+ /** Sanitize pasted HTML against an allowlist (on by default). Set in JS to disable. */
39
+ sanitizePaste: boolean;
40
+ /** Custom paste sanitizer `(html) => html`; defaults to the built-in allowlist `sanitizeHtml`. */
41
+ pasteSanitizer?: (html: string) => string;
42
+ /** Bumped on every selection change to re-render the toolbar's active state. */
43
+ private selVersion;
44
+ /** Becomes true once the editor is built; gates toolbar rendering so plugin toolbar items that
45
+ * read the editor (block-type select, list state) never run before the editor exists. */
46
+ private ready;
47
+ constructor();
48
+ /** Read-only access to the live Lexical editor, so export code outside a plugin can reach it.
49
+ * The editor is REPLACED when `plugins` changes (serialize → recreate → deserialize) — do not
50
+ * cache the returned instance across a rebuild. `undefined` until the first update completes. */
51
+ get editor(): LexicalEditor | undefined;
52
+ get validity(): ValidityState;
53
+ checkValidity(): boolean;
54
+ focus(o?: FocusOptions): void;
55
+ formResetCallback(): void;
56
+ disconnectedCallback(): void;
57
+ protected updated(c: Map<PropertyKey, unknown>): void;
58
+ /** Replace the document with the given HTML (regardless of the active `format`). */
59
+ setHtml(htmlString: string): void;
60
+ render(): TemplateResult<1>;
61
+ }
62
+ export default DjRichText;
63
+ declare global {
64
+ interface GlobalEventHandlersEventMap {
65
+ "dj-change": CustomEvent<Record<string, never>>;
66
+ }
67
+ }
@@ -0,0 +1,364 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
8
+ if (kind === "m") throw new TypeError("Private method is not writable");
9
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
10
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
11
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
12
+ };
13
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
14
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
15
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
16
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
17
+ };
18
+ var _DjRichText_instances, _DjRichText_internals, _DjRichText_editor, _DjRichText_applyingValue, _DjRichText_built, _DjRichText_disposers, _DjRichText_active, _DjRichText_selectionSubs, _DjRichText_serializers, _DjRichText_plugins, _DjRichText_ctx, _DjRichText_resolvePlugins, _DjRichText_currentFormat, _DjRichText_buildEditor, _DjRichText_teardown, _DjRichText_serialize, _DjRichText_syncValue, _DjRichText_applyValue, _DjRichText_icon, _DjRichText_renderItem, _DjRichText_toolbarItems;
19
+ import { html, nothing } from "lit";
20
+ import { property, state, query } from "lit/decorators.js";
21
+ import DojoElement, { FormControl } from "@dojo-ng/dojo-element";
22
+ import "@dojo-ng/button";
23
+ import { createEditor, $getRoot, $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, } from "lexical";
24
+ import { registerRichText } from "@lexical/rich-text";
25
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
26
+ import { $insertGeneratedNodes } from "@lexical/clipboard";
27
+ import { mergeRegister } from "@lexical/utils";
28
+ import { defaultPlugins } from "./default-plugins.js";
29
+ import { sanitizeHtml } from "./sanitize-html.js";
30
+ /** Inline text formats inspected for toolbar active state. */
31
+ const TEXT_FORMATS = [
32
+ "bold", "italic", "underline", "strikethrough", "code", "subscript", "superscript", "highlight",
33
+ ];
34
+ /** Built-in HTML serializer. `serialize` runs inside an editor read; `deserialize` inside an update. */
35
+ const HTML_FORMAT = {
36
+ serialize: (editor) => $generateHtmlFromNodes(editor, null),
37
+ deserialize: (editor, data) => {
38
+ const root = $getRoot();
39
+ root.clear();
40
+ const dom = new DOMParser().parseFromString(data || "<p></p>", "text/html");
41
+ for (const node of $generateNodesFromDOM(editor, dom))
42
+ root.append(node);
43
+ },
44
+ };
45
+ /**
46
+ * `<dj-rich-text>` — a form-associated WYSIWYG editor built on the Lexical core. The editable
47
+ * region renders in LIGHT DOM (Lexical's selection handling is not reliable inside a shadow root
48
+ * yet), so this component overrides `createRenderRoot`; theming still works because `--dj-*` tokens
49
+ * cascade in light DOM.
50
+ *
51
+ * The editor is a PLUGIN HOST: bold/italic/underline and undo/redo ship as the default plugin set
52
+ * (`default-plugins.ts`) and flow through the same {@link RichTextPlugin} API third-party plugins
53
+ * use. Foundational behavior (`registerRichText`, value sync, root-element setup) stays as core.
54
+ * Toolbar controls, node registration, and output formats all come from plugins.
55
+ *
56
+ * Constraint: Lexical needs node classes at creation, so a `plugins` change after creation rebuilds
57
+ * the editor (serialize → recreate → deserialize). Value is HTML by default; the `format` property
58
+ * selects an alternate serializer contributed by a plugin. Event: `dj-change`.
59
+ */
60
+ export class DjRichText extends FormControl(DojoElement) {
61
+ // Light DOM: required for Lexical selection to work today.
62
+ createRenderRoot() { return this; }
63
+ constructor() {
64
+ super();
65
+ _DjRichText_instances.add(this);
66
+ _DjRichText_internals.set(this, void 0);
67
+ _DjRichText_editor.set(this, void 0);
68
+ _DjRichText_applyingValue.set(this, false);
69
+ _DjRichText_built.set(this, false);
70
+ _DjRichText_disposers.set(this, []);
71
+ _DjRichText_active.set(this, new Set());
72
+ _DjRichText_selectionSubs.set(this, new Set());
73
+ _DjRichText_serializers.set(this, { html: HTML_FORMAT });
74
+ _DjRichText_plugins.set(this, []);
75
+ _DjRichText_ctx.set(this, void 0);
76
+ this.value = "";
77
+ this.placeholder = "";
78
+ this.disabled = false;
79
+ /** Plugin set. Set in JavaScript (rich data). The default set is used when this is empty. */
80
+ this.plugins = [];
81
+ /** Output format; selects which serializer the value getter/setter uses. `"html"` is built in. */
82
+ this.format = "html";
83
+ /** Sanitize pasted HTML against an allowlist (on by default). Set in JS to disable. */
84
+ this.sanitizePaste = true;
85
+ /** Bumped on every selection change to re-render the toolbar's active state. */
86
+ this.selVersion = 0;
87
+ /** Becomes true once the editor is built; gates toolbar rendering so plugin toolbar items that
88
+ * read the editor (block-type select, list state) never run before the editor exists. */
89
+ this.ready = false;
90
+ __classPrivateFieldSet(this, _DjRichText_internals, this.attachInternals(), "f");
91
+ // Stable context object — safe to read before the editor exists (activeFormats() is empty,
92
+ // command() is a no-op until then). `editor` is a live getter because rebuilds replace it.
93
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- stable context object needs a captured host for its live `editor` getter
94
+ const host = this;
95
+ __classPrivateFieldSet(this, _DjRichText_ctx, {
96
+ get editor() { return __classPrivateFieldGet(host, _DjRichText_editor, "f"); },
97
+ host,
98
+ command(type, payload) {
99
+ __classPrivateFieldGet(host, _DjRichText_editor, "f")?.dispatchCommand(type, payload);
100
+ host.focus();
101
+ },
102
+ onSelectionChange(cb) {
103
+ __classPrivateFieldGet(host, _DjRichText_selectionSubs, "f").add(cb);
104
+ return () => __classPrivateFieldGet(host, _DjRichText_selectionSubs, "f").delete(cb);
105
+ },
106
+ activeFormats: () => __classPrivateFieldGet(host, _DjRichText_active, "f"),
107
+ get plugins() { return __classPrivateFieldGet(host, _DjRichText_plugins, "f"); },
108
+ }, "f");
109
+ }
110
+ /** Read-only access to the live Lexical editor, so export code outside a plugin can reach it.
111
+ * The editor is REPLACED when `plugins` changes (serialize → recreate → deserialize) — do not
112
+ * cache the returned instance across a rebuild. `undefined` until the first update completes. */
113
+ get editor() { return __classPrivateFieldGet(this, _DjRichText_editor, "f"); }
114
+ get validity() { return __classPrivateFieldGet(this, _DjRichText_internals, "f").validity; }
115
+ checkValidity() { return __classPrivateFieldGet(this, _DjRichText_internals, "f").checkValidity(); }
116
+ focus(o) { this.editable?.focus(o); }
117
+ formResetCallback() { __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_applyValue).call(this, this.getAttribute("value") ?? "", HTML_FORMAT); }
118
+ disconnectedCallback() {
119
+ super.disconnectedCallback();
120
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_teardown).call(this);
121
+ __classPrivateFieldSet(this, _DjRichText_built, false, "f");
122
+ }
123
+ updated(c) {
124
+ if (!__classPrivateFieldGet(this, _DjRichText_editor, "f")) {
125
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_buildEditor).call(this);
126
+ }
127
+ else if (c.has("plugins") && c.get("plugins") !== undefined) {
128
+ // Genuine post-creation plugin change: rebuild around the new node set, preserving value.
129
+ this.value = __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_serialize).call(this);
130
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_teardown).call(this);
131
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_buildEditor).call(this);
132
+ }
133
+ if (c.has("disabled")) {
134
+ __classPrivateFieldGet(this, _DjRichText_editor, "f")?.setEditable(!this.isDisabled);
135
+ if (this.editable)
136
+ this.editable.contentEditable = this.isDisabled ? "false" : "true";
137
+ }
138
+ if (c.has("format") && !c.has("plugins") && __classPrivateFieldGet(this, _DjRichText_built, "f"))
139
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_syncValue).call(this);
140
+ }
141
+ /** Replace the document with the given HTML (regardless of the active `format`). */
142
+ setHtml(htmlString) { __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_applyValue).call(this, htmlString, HTML_FORMAT); }
143
+ render() {
144
+ // Only build toolbar items once the editor exists — plugin items may read ctx.editor.
145
+ const items = this.ready && __classPrivateFieldGet(this, _DjRichText_editor, "f") ? __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_toolbarItems).call(this) : [];
146
+ let lastGroup;
147
+ const toolbar = items.map((item, idx) => {
148
+ const sep = idx > 0 && item.group !== lastGroup
149
+ ? html `<span class="dj-rt-sep" role="separator" aria-orientation="vertical"></span>`
150
+ : nothing;
151
+ lastGroup = item.group;
152
+ return html `${sep}${__classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_renderItem).call(this, item)}`;
153
+ });
154
+ return html `
155
+ <style>
156
+ dj-rich-text { display: block; font-family: var(--dj-font-family, inherit); }
157
+ dj-rich-text .dj-rt-toolbar { display: flex; align-items: center; gap: .15rem; padding: .25rem; border: 1px solid var(--dj-color-border, #d1d5db); border-bottom: none; border-radius: var(--dj-input-border-radius-medium, .25rem) var(--dj-input-border-radius-medium, .25rem) 0 0; background: var(--dj-color-neutral-50, #f9fafb); }
158
+ dj-rich-text .dj-rt-sep { align-self: stretch; width: 1px; margin: .15rem .25rem; background: var(--dj-color-border, #d1d5db); }
159
+ dj-rich-text .dj-rt-editable { min-height: 8rem; padding: var(--dj-spacing-small, .75rem); border: 1px solid var(--dj-color-border, #d1d5db); border-radius: 0 0 var(--dj-input-border-radius-medium, .25rem) var(--dj-input-border-radius-medium, .25rem); outline: none; background: var(--dj-color-background, #fff); color: var(--dj-color-text, #1f2937); }
160
+ dj-rich-text .dj-rt-editable:focus { border-color: var(--dj-color-primary-600, #2563eb); box-shadow: 0 0 0 1px var(--dj-color-primary-600, #2563eb); }
161
+ @media (forced-colors: active) { dj-rich-text .dj-rt-editable:focus { outline: 2px solid Highlight; outline-offset: -1px; } }
162
+ dj-rich-text[disabled] { opacity: .5; }
163
+ @media print {
164
+ dj-rich-text .dj-rt-toolbar { display: none; }
165
+ dj-rich-text .dj-rt-editable { border: none; min-height: 0; padding: 0; }
166
+ }
167
+ </style>
168
+ ${this.label ? html `<label style="display:block;margin-bottom:.25rem;color:var(--dj-color-neutral-700,#374151)">${this.label}</label>` : ""}
169
+ <div class="dj-rt-toolbar" role="toolbar" aria-label="Formatting">${toolbar}</div>
170
+ <div class="dj-rt-editable" role="textbox" aria-multiline="true" aria-label=${this.label || "Rich text editor"} data-placeholder=${this.placeholder}></div>
171
+ `;
172
+ }
173
+ }
174
+ _DjRichText_internals = new WeakMap(), _DjRichText_editor = new WeakMap(), _DjRichText_applyingValue = new WeakMap(), _DjRichText_built = new WeakMap(), _DjRichText_disposers = new WeakMap(), _DjRichText_active = new WeakMap(), _DjRichText_selectionSubs = new WeakMap(), _DjRichText_serializers = new WeakMap(), _DjRichText_plugins = new WeakMap(), _DjRichText_ctx = new WeakMap(), _DjRichText_instances = new WeakSet(), _DjRichText_resolvePlugins = function _DjRichText_resolvePlugins() {
175
+ return this.plugins.length ? this.plugins : defaultPlugins;
176
+ }, _DjRichText_currentFormat = function _DjRichText_currentFormat() {
177
+ return __classPrivateFieldGet(this, _DjRichText_serializers, "f")[this.format] ?? HTML_FORMAT;
178
+ }, _DjRichText_buildEditor = function _DjRichText_buildEditor() {
179
+ const plugins = __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_resolvePlugins).call(this);
180
+ __classPrivateFieldSet(this, _DjRichText_plugins, plugins, "f"); // expose the resolved set via ctx.plugins (for the slash menu, etc.)
181
+ // Collect node classes contributed by plugins, de-duplicated. The core registers no nodes of
182
+ // its own: heading/quote/list nodes come from their plugins, so an editor only pays for what
183
+ // it loads (an editor without the headings plugin renders pasted headings as paragraphs).
184
+ const nodes = [...new Set(plugins.flatMap((p) => p.nodes ?? []))];
185
+ // Build the format-serializer registry from plugin-contributed formats.
186
+ __classPrivateFieldSet(this, _DjRichText_serializers, { html: HTML_FORMAT }, "f");
187
+ for (const p of plugins)
188
+ if (p.formats)
189
+ Object.assign(__classPrivateFieldGet(this, _DjRichText_serializers, "f"), p.formats);
190
+ // Merge plugin-contributed HTML import/export overrides (later plugins win on tag collisions).
191
+ // This is how the color plugin preserves inline color/background on `value` round-trips, which
192
+ // the default DOM import would otherwise strip.
193
+ const htmlImport = {};
194
+ const htmlExport = new Map();
195
+ for (const p of plugins) {
196
+ if (p.html?.import)
197
+ Object.assign(htmlImport, p.html.import);
198
+ if (p.html?.export)
199
+ for (const [k, v] of p.html.export)
200
+ htmlExport.set(k, v);
201
+ }
202
+ const editor = createEditor({
203
+ namespace: "dj-rich-text",
204
+ nodes,
205
+ onError: (e) => console.error("[dj-rich-text]", e),
206
+ html: { import: htmlImport, export: htmlExport },
207
+ });
208
+ __classPrivateFieldSet(this, _DjRichText_editor, editor, "f");
209
+ // Lexical's vanilla setRootElement does NOT make the element editable; we own that.
210
+ this.editable.contentEditable = this.isDisabled ? "false" : "true";
211
+ this.editable.spellcheck = true;
212
+ editor.setRootElement(this.editable);
213
+ editor.setEditable(!this.isDisabled);
214
+ __classPrivateFieldSet(this, _DjRichText_disposers, [
215
+ registerRichText(editor),
216
+ // Sanitize pasted HTML before Lexical's own paste handler runs (higher priority + return
217
+ // true stops it). Plain-text pastes (no text/html) fall through to the safe default.
218
+ editor.registerCommand(PASTE_COMMAND, (event) => {
219
+ if (!this.sanitizePaste)
220
+ return false;
221
+ const cd = event.clipboardData;
222
+ const raw = cd?.getData("text/html");
223
+ if (!raw)
224
+ return false; // no HTML → let the default handle text/plain
225
+ event.preventDefault();
226
+ const clean = (this.pasteSanitizer ?? sanitizeHtml)(raw);
227
+ editor.update(() => {
228
+ const sel = $getSelection();
229
+ if (!$isRangeSelection(sel))
230
+ return;
231
+ const dom = new DOMParser().parseFromString(clean || "", "text/html");
232
+ const nodes = $generateNodesFromDOM(editor, dom);
233
+ // Route through Lexical's own clipboard-insert command (handled by registerRichText),
234
+ // not a bare `selection.insertNodes`: block/"shadow-root" nodes like tables are
235
+ // flattened to their text by the naive path in real browsers, but inserted intact by
236
+ // the command handler. Falls back to insertNodes when nothing consumes the command.
237
+ $insertGeneratedNodes(editor, nodes, sel);
238
+ });
239
+ return true;
240
+ }, COMMAND_PRIORITY_HIGH),
241
+ editor.registerUpdateListener(({ editorState }) => {
242
+ editorState.read(() => {
243
+ const sel = $getSelection();
244
+ __classPrivateFieldSet(this, _DjRichText_active, $isRangeSelection(sel)
245
+ ? new Set(TEXT_FORMATS.filter((f) => sel.hasFormat(f)))
246
+ : new Set(), "f");
247
+ });
248
+ this.selVersion++; // re-render toolbar active state
249
+ for (const cb of __classPrivateFieldGet(this, _DjRichText_selectionSubs, "f"))
250
+ cb();
251
+ if (__classPrivateFieldGet(this, _DjRichText_applyingValue, "f"))
252
+ return; // programmatic value set: don't echo back
253
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_syncValue).call(this);
254
+ }),
255
+ ], "f");
256
+ // Run each plugin's setup and compose its disposer.
257
+ for (const p of plugins) {
258
+ const dispose = p.setup?.(__classPrivateFieldGet(this, _DjRichText_ctx, "f"));
259
+ if (dispose)
260
+ __classPrivateFieldGet(this, _DjRichText_disposers, "f").push(dispose);
261
+ }
262
+ __classPrivateFieldSet(this, _DjRichText_built, true, "f");
263
+ this.ready = true; // re-render so the toolbar shows now that ctx.editor is live
264
+ if (this.value)
265
+ __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_applyValue).call(this, this.value, __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_currentFormat).call(this));
266
+ }, _DjRichText_teardown = function _DjRichText_teardown() {
267
+ mergeRegister(...__classPrivateFieldGet(this, _DjRichText_disposers, "f"))();
268
+ __classPrivateFieldSet(this, _DjRichText_disposers, [], "f");
269
+ __classPrivateFieldGet(this, _DjRichText_editor, "f")?.setRootElement(null);
270
+ __classPrivateFieldSet(this, _DjRichText_editor, undefined, "f");
271
+ this.ready = false;
272
+ }, _DjRichText_serialize = function _DjRichText_serialize() {
273
+ const editor = __classPrivateFieldGet(this, _DjRichText_editor, "f");
274
+ if (!editor)
275
+ return this.value;
276
+ const fmt = __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_currentFormat).call(this);
277
+ return editor.getEditorState().read(() => fmt.serialize(editor));
278
+ }, _DjRichText_syncValue = function _DjRichText_syncValue() {
279
+ const out = __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_serialize).call(this);
280
+ if (out !== this.value) {
281
+ this.value = out;
282
+ __classPrivateFieldGet(this, _DjRichText_internals, "f").setFormValue(out);
283
+ this.emit("dj-change");
284
+ }
285
+ }, _DjRichText_applyValue = function _DjRichText_applyValue(data, fmt) {
286
+ const editor = __classPrivateFieldGet(this, _DjRichText_editor, "f");
287
+ if (!editor) {
288
+ this.value = data;
289
+ return;
290
+ }
291
+ __classPrivateFieldSet(this, _DjRichText_applyingValue, true, "f");
292
+ editor.update(() => fmt.deserialize(editor, data), {
293
+ onUpdate: () => { __classPrivateFieldSet(this, _DjRichText_applyingValue, false, "f"); },
294
+ });
295
+ }, _DjRichText_icon = function _DjRichText_icon(icon) {
296
+ return icon === undefined ? nothing : typeof icon === "string" ? html `${icon}` : icon;
297
+ }, _DjRichText_renderItem = function _DjRichText_renderItem(item) {
298
+ if (item.render)
299
+ return item.render(__classPrivateFieldGet(this, _DjRichText_ctx, "f"));
300
+ const pressed = item.isActive ? String(item.isActive(__classPrivateFieldGet(this, _DjRichText_ctx, "f"))) : nothing;
301
+ return html `<dj-button
302
+ kind="text"
303
+ aria-pressed=${pressed}
304
+ ?disabled=${item.isDisabled ? item.isDisabled(__classPrivateFieldGet(this, _DjRichText_ctx, "f")) : false}
305
+ aria-label=${item.label}
306
+ @click=${() => item.run?.(__classPrivateFieldGet(this, _DjRichText_ctx, "f"))}
307
+ >${__classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_icon).call(this, item.icon)}</dj-button>`;
308
+ }, _DjRichText_toolbarItems = function _DjRichText_toolbarItems() {
309
+ const items = [];
310
+ // Keep plugins in array order; sort only WITHIN each plugin by `order` (a stable sort, so
311
+ // items without an `order` keep their relative position). Sorting globally would interleave
312
+ // controls from different plugins.
313
+ for (const p of __classPrivateFieldGet(this, _DjRichText_instances, "m", _DjRichText_resolvePlugins).call(this)) {
314
+ const t = typeof p.toolbar === "function" ? p.toolbar(__classPrivateFieldGet(this, _DjRichText_ctx, "f")) : p.toolbar;
315
+ if (!t)
316
+ continue;
317
+ const sorted = t
318
+ .map((item, i) => ({ item, i }))
319
+ .sort((a, b) => (a.item.order ?? 0) - (b.item.order ?? 0) || a.i - b.i)
320
+ .map(({ item }) => item);
321
+ items.push(...sorted);
322
+ }
323
+ return items;
324
+ };
325
+ DjRichText.version = "0.1.0";
326
+ DjRichText.focusable = true;
327
+ DjRichText.formAssociated = true;
328
+ __decorate([
329
+ query(".dj-rt-editable")
330
+ ], DjRichText.prototype, "editable", void 0);
331
+ __decorate([
332
+ property()
333
+ ], DjRichText.prototype, "value", void 0);
334
+ __decorate([
335
+ property({ reflect: true })
336
+ ], DjRichText.prototype, "name", void 0);
337
+ __decorate([
338
+ property()
339
+ ], DjRichText.prototype, "label", void 0);
340
+ __decorate([
341
+ property()
342
+ ], DjRichText.prototype, "placeholder", void 0);
343
+ __decorate([
344
+ property({ type: Boolean, reflect: true })
345
+ ], DjRichText.prototype, "disabled", void 0);
346
+ __decorate([
347
+ property({ attribute: false })
348
+ ], DjRichText.prototype, "plugins", void 0);
349
+ __decorate([
350
+ property({ reflect: true })
351
+ ], DjRichText.prototype, "format", void 0);
352
+ __decorate([
353
+ property({ attribute: false, type: Boolean })
354
+ ], DjRichText.prototype, "sanitizePaste", void 0);
355
+ __decorate([
356
+ property({ attribute: false })
357
+ ], DjRichText.prototype, "pasteSanitizer", void 0);
358
+ __decorate([
359
+ state()
360
+ ], DjRichText.prototype, "selVersion", void 0);
361
+ __decorate([
362
+ state()
363
+ ], DjRichText.prototype, "ready", void 0);
364
+ export default DjRichText;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Idempotent, document-level style injection for light-DOM rich-text plugins.
3
+ *
4
+ * The `dj-rich-text` component renders in the light DOM, so plugin content styles must live as global
5
+ * selectors (e.g. `dj-rich-text table { ... }`) in `document.head`. Plugins that add editable content
6
+ * (tables, checklists, mentions, embeds) call `ensureEditorStyles(id, css)` once during `setup`; the
7
+ * `id` guards against duplicate `<style>` elements when several editors mount.
8
+ */
9
+ /**
10
+ * Append a `<style id={id}>` with the given CSS to `document.head`, unless an element with that id
11
+ * already exists. Safe to call repeatedly with the same id (a no-op after the first call). No-op when
12
+ * there is no `document` (e.g. a non-DOM environment).
13
+ */
14
+ export declare function ensureEditorStyles(id: string, css: string): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Idempotent, document-level style injection for light-DOM rich-text plugins.
3
+ *
4
+ * The `dj-rich-text` component renders in the light DOM, so plugin content styles must live as global
5
+ * selectors (e.g. `dj-rich-text table { ... }`) in `document.head`. Plugins that add editable content
6
+ * (tables, checklists, mentions, embeds) call `ensureEditorStyles(id, css)` once during `setup`; the
7
+ * `id` guards against duplicate `<style>` elements when several editors mount.
8
+ */
9
+ /**
10
+ * Append a `<style id={id}>` with the given CSS to `document.head`, unless an element with that id
11
+ * already exists. Safe to call repeatedly with the same id (a no-op after the first call). No-op when
12
+ * there is no `document` (e.g. a non-DOM environment).
13
+ */
14
+ export function ensureEditorStyles(id, css) {
15
+ if (typeof document === "undefined" || !document.head)
16
+ return;
17
+ if (document.getElementById(id))
18
+ return;
19
+ const style = document.createElement("style");
20
+ style.id = id;
21
+ style.textContent = css;
22
+ document.head.appendChild(style);
23
+ }
@@ -0,0 +1,12 @@
1
+ import { DjRichText } from "./dj-rich-text.js";
2
+ export * from "./dj-rich-text.js";
3
+ export * from "./plugin.js";
4
+ export * from "./default-plugins.js";
5
+ export { sanitizeHtml } from "./sanitize-html.js";
6
+ export { ensureEditorStyles } from "./editor-styles.js";
7
+ export default DjRichText;
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ "dj-rich-text": DjRichText;
11
+ }
12
+ }
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import { DjRichText } from "./dj-rich-text.js";
2
+ export * from "./dj-rich-text.js";
3
+ export * from "./plugin.js";
4
+ export * from "./default-plugins.js";
5
+ export { sanitizeHtml } from "./sanitize-html.js";
6
+ export { ensureEditorStyles } from "./editor-styles.js";
7
+ export default DjRichText;
8
+ DjRichText.define("dj-rich-text", DjRichText);
@@ -0,0 +1,102 @@
1
+ import type { LexicalCommand, LexicalEditor, LexicalNode, Klass, DOMConversionMap, DOMExportOutputMap } from "lexical";
2
+ import type { TemplateResult } from "lit";
3
+ /**
4
+ * Plugin API for `<dj-rich-text>`. A plugin is a plain object (or a factory returning one) that
5
+ * can contribute Lexical nodes, register commands/transforms/listeners, add toolbar controls, and
6
+ * supply alternate output formats. The component's own bold/italic/underline and undo/redo ship as
7
+ * the default plugin set (see `default-plugins.ts`), so even the basics flow through this API.
8
+ *
9
+ * KEY CONSTRAINT: Lexical needs its custom node classes at editor-creation time, so a plugin that
10
+ * contributes `nodes` must be declared before the editor is built. Changing the `plugins` property
11
+ * after creation rebuilds the editor (serialize → recreate → deserialize).
12
+ */
13
+ /** What a plugin can see and do. Passed to `setup()` and to toolbar item callbacks. */
14
+ export interface RichTextContext {
15
+ /** The live Lexical editor. */
16
+ readonly editor: LexicalEditor;
17
+ /** The `dj-rich-text` host element (focus, events, requestUpdate). */
18
+ readonly host: HTMLElement;
19
+ /** Dispatch a Lexical command, then return focus to the editor. */
20
+ command<P>(type: LexicalCommand<P>, payload: P): void;
21
+ /** Subscribe to selection/format changes; returns a disposer. Drives toolbar active state. */
22
+ onSelectionChange(cb: () => void): () => void;
23
+ /** The current selection's active inline formats (`bold`, `italic`, …). */
24
+ activeFormats(): ReadonlySet<string>;
25
+ /**
26
+ * The resolved plugin list the editor was built with (empty array before the editor is built).
27
+ * Used by aggregating plugins such as the slash menu to gather every loaded plugin's `inserts`.
28
+ */
29
+ readonly plugins: readonly RichTextPlugin[];
30
+ }
31
+ /**
32
+ * A block/insert action a plugin exposes to a command menu (the slash menu). Aggregated across all
33
+ * loaded plugins by `@dojo-ng/rich-text-slash`. When run, the menu has already removed the "/query"
34
+ * trigger text and the caret sits in that block. `run` executes within the removal `editor.update` so
35
+ * a block transform keeps the caret's selection (a separate update would lose it); a nested
36
+ * `editor.update`, a command dispatch, or opening a dialog all work from here.
37
+ */
38
+ export interface RichTextInsertItem {
39
+ id: string;
40
+ label: string;
41
+ icon?: TemplateResult | string;
42
+ keywords?: string[];
43
+ run(ctx: RichTextContext): void;
44
+ }
45
+ /**
46
+ * A toolbar control contributed by a plugin. Either a simple command button (`run` + optional
47
+ * `icon`/`isActive`/`isDisabled`) or a fully custom control (`render`). `label` is the accessible
48
+ * name and is always required so plugins cannot regress accessibility.
49
+ */
50
+ export interface RichTextToolbarItem {
51
+ id: string;
52
+ /** Controls grouping; a separator is rendered between items of differing groups. */
53
+ group?: string;
54
+ /** Sort order within the toolbar (ascending; ties keep insertion order). */
55
+ order?: number;
56
+ /** Accessible name (required). */
57
+ label: string;
58
+ icon?: TemplateResult | string;
59
+ /** Reflected as `aria-pressed`. Re-evaluated on selection change. */
60
+ isActive?(ctx: RichTextContext): boolean;
61
+ isDisabled?(ctx: RichTextContext): boolean;
62
+ run?(ctx: RichTextContext): void;
63
+ render?(ctx: RichTextContext): TemplateResult;
64
+ }
65
+ /**
66
+ * An alternate output format a plugin can add (for example Markdown). The component invokes
67
+ * `serialize` inside an editor read scope and `deserialize` inside an editor update scope, so
68
+ * implementations call the `$`-prefixed Lexical helpers directly without their own `read`/`update`.
69
+ */
70
+ export interface RichTextFormat {
71
+ serialize(editor: LexicalEditor): string;
72
+ deserialize(editor: LexicalEditor, data: string): void;
73
+ }
74
+ export interface RichTextPlugin {
75
+ name: string;
76
+ /** Custom nodes this plugin needs. Registered at editor creation; see the rebuild note above. */
77
+ nodes?: Klass<LexicalNode>[];
78
+ /** Register commands, transforms, and listeners. Return a disposer (or compose with `mergeRegister`). */
79
+ setup?(ctx: RichTextContext): (() => void) | void;
80
+ /** Toolbar controls, static or derived from context. */
81
+ toolbar?: RichTextToolbarItem[] | ((ctx: RichTextContext) => RichTextToolbarItem[]);
82
+ /** Optional alternate output formats, keyed by name (for example `"markdown"`). */
83
+ formats?: Record<string, RichTextFormat>;
84
+ /**
85
+ * Block/insert actions this plugin exposes to a command menu (the slash menu). Static, or a
86
+ * function of context so a plugin can vary its offerings. Resolved once per menu open.
87
+ */
88
+ inserts?: RichTextInsertItem[] | ((ctx: RichTextContext) => RichTextInsertItem[]);
89
+ /**
90
+ * Optional HTML import/export overrides passed to `createEditor`'s `html` config. `import` is a
91
+ * `DOMConversionMap` merged across plugins (later plugins win on tag collisions); it lets a plugin
92
+ * preserve markup the default converters drop — for example the color plugin uses it to keep inline
93
+ * `color`/`background-color` on `value` round-trips (the default DOM import strips them). `export`
94
+ * is the matching `DOMExportOutputMap`.
95
+ */
96
+ html?: {
97
+ import?: DOMConversionMap;
98
+ export?: DOMExportOutputMap;
99
+ };
100
+ }
101
+ /** Identity helper for typing and authoring a plugin. */
102
+ export declare const defineRichTextPlugin: (p: RichTextPlugin) => RichTextPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Identity helper for typing and authoring a plugin. */
2
+ export const defineRichTextPlugin = (p) => p;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Allowlist HTML sanitizer for pasted content. Security and consistency hook: strips anything not
3
+ * on the allowlist so pasted markup can't smuggle scripts, event handlers, inline styles, or unsafe
4
+ * URLs into the editor, and pastes land as clean structural/formatting HTML.
5
+ *
6
+ * It parses with `DOMParser` (which does NOT execute scripts), walks the tree, removes dangerous
7
+ * elements outright, unwraps other disallowed elements (keeping their text), strips every attribute
8
+ * not explicitly allowed for a tag, and drops unsafe `href`/`src` URLs. Returns clean HTML.
9
+ */
10
+ /** Sanitize an HTML string to the allowlist and return clean HTML. */
11
+ export declare function sanitizeHtml(input: string): string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Allowlist HTML sanitizer for pasted content. Security and consistency hook: strips anything not
3
+ * on the allowlist so pasted markup can't smuggle scripts, event handlers, inline styles, or unsafe
4
+ * URLs into the editor, and pastes land as clean structural/formatting HTML.
5
+ *
6
+ * It parses with `DOMParser` (which does NOT execute scripts), walks the tree, removes dangerous
7
+ * elements outright, unwraps other disallowed elements (keeping their text), strips every attribute
8
+ * not explicitly allowed for a tag, and drops unsafe `href`/`src` URLs. Returns clean HTML.
9
+ */
10
+ /** Allowed elements → the attributes each may keep. Everything else is stripped. */
11
+ const ALLOWED = {
12
+ a: ["href", "title", "target", "rel"],
13
+ p: [], br: [], hr: [],
14
+ h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
15
+ blockquote: [], pre: [], code: [],
16
+ ul: [], ol: ["start", "type"], li: [],
17
+ strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [],
18
+ sub: [], sup: [], mark: [], span: [],
19
+ table: [], thead: [], tbody: [], tfoot: [], tr: [], caption: [],
20
+ th: ["colspan", "rowspan"], td: ["colspan", "rowspan"],
21
+ };
22
+ /** Elements removed entirely, content and all (never merely unwrapped). */
23
+ const REMOVE = new Set([
24
+ "script", "style", "iframe", "object", "embed", "noscript", "template", "link", "meta", "base",
25
+ "head", "title", "form", "input", "button", "textarea", "select", "option", "svg", "math",
26
+ "audio", "video", "source", "track", "canvas", "applet", "frame", "frameset",
27
+ ]);
28
+ /** True for URLs safe to keep: http(s)/mailto/tel, or relative/anchor. Any other scheme is rejected. */
29
+ function safeUrl(url) {
30
+ const v = url.trim();
31
+ if (/^(https?:|mailto:|tel:)/i.test(v))
32
+ return true;
33
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v))
34
+ return false; // some other scheme (javascript:, data:, vbscript:, …)
35
+ return true; // relative path, query, or #anchor
36
+ }
37
+ function stripAttrs(el, allowed) {
38
+ for (const attr of Array.from(el.attributes)) {
39
+ const name = attr.name.toLowerCase();
40
+ if (!allowed.includes(name)) {
41
+ el.removeAttribute(attr.name);
42
+ continue;
43
+ }
44
+ if ((name === "href" || name === "src") && !safeUrl(attr.value)) {
45
+ el.removeAttribute(attr.name);
46
+ }
47
+ }
48
+ }
49
+ /** Depth-first: clean descendants, then keep-and-strip (allowed) or unwrap (disallowed) each child. */
50
+ function sanitizeChildren(parent) {
51
+ for (const child of Array.from(parent.children)) {
52
+ const tag = child.tagName.toLowerCase();
53
+ if (REMOVE.has(tag)) {
54
+ child.remove();
55
+ continue;
56
+ }
57
+ sanitizeChildren(child); // clean the subtree first, so unwrapped children are already clean
58
+ const allowed = ALLOWED[tag];
59
+ if (allowed) {
60
+ stripAttrs(child, allowed);
61
+ }
62
+ else {
63
+ // Unwrap: promote the (already-cleaned) children in place of the disallowed element.
64
+ while (child.firstChild)
65
+ parent.insertBefore(child.firstChild, child);
66
+ child.remove();
67
+ }
68
+ }
69
+ }
70
+ /** Sanitize an HTML string to the allowlist and return clean HTML. */
71
+ export function sanitizeHtml(input) {
72
+ const doc = new DOMParser().parseFromString(input ?? "", "text/html");
73
+ sanitizeChildren(doc.body);
74
+ return doc.body.innerHTML;
75
+ }
package/package.json ADDED
@@ -0,0 +1,5 @@
1
+ { "name": "@dojo-ng/rich-text", "version": "0.1.0", "license": "BSD-3-Clause", "description": "Dojo NG rich-text (WYSIWYG) web component on Lexical", "type": "module",
2
+ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts",
3
+ "files": ["dist"],
4
+ "scripts": { "build": "tsc -p tsconfig.json", "clean": "rm -rf dist" },
5
+ "dependencies": { "@dojo-ng/dojo-element": "^0.1.0", "@dojo-ng/button": "^0.1.0", "lexical": "^0.21.0", "@lexical/rich-text": "^0.21.0", "@lexical/history": "^0.21.0", "@lexical/html": "^0.21.0", "@lexical/clipboard": "^0.21.0", "@lexical/utils": "^0.21.0", "lit": "^3.3.0" } }