@richscripts/richtexteditor 2.6.3 → 2.6.4

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.
@@ -60,6 +60,9 @@ export class RichTextEditorAngularComponent implements AfterViewInit, OnChanges,
60
60
  @Input() config: Record<string, unknown> = {};
61
61
  @Input() value?: RichTextEditorStructuredInput;
62
62
  @Input() valueFormat: RichTextEditorValueFormat = "html";
63
+ // Quiet period before the document is serialised and `change` is emitted.
64
+ // 0 restores the pre-2.6 behaviour of emitting on every keystroke.
65
+ @Input() changeDelay = 120;
63
66
 
64
67
  @Output() change = new EventEmitter<RichTextEditorAngularChangeEvent>();
65
68
  @Output() error = new EventEmitter<Error>();
@@ -68,6 +71,8 @@ export class RichTextEditorAngularComponent implements AfterViewInit, OnChanges,
68
71
  @ViewChild("host", { static: true }) hostRef!: ElementRef<HTMLDivElement>;
69
72
 
70
73
  private changeHandler: (() => void) | null = null;
74
+ private emitChange: ((() => void) & { flush(): void; cancel(): void }) | null = null;
75
+ private lastEmitted: unknown;
71
76
  private editor: RichTextEditorInstance | null = null;
72
77
 
73
78
  async ngAfterViewInit(): Promise<void> {
@@ -84,16 +89,22 @@ export class RichTextEditorAngularComponent implements AfterViewInit, OnChanges,
84
89
  this.syncEditor(this.value);
85
90
  }
86
91
 
87
- this.changeHandler = () => {
92
+ // Coalesce: the editor fires `change` per keystroke and getEditorValue
93
+ // serialises the whole document (78 ms on a 3000-paragraph document).
94
+ // See shared.createChangeCoalescer.
95
+ this.emitChange = shared.createChangeCoalescer(() => {
88
96
  if (!this.editor) {
89
97
  return;
90
98
  }
91
99
 
100
+ this.lastEmitted = shared.getEditorValue(this.editor, this.valueFormat);
92
101
  this.change.emit({
93
102
  editor: this.editor,
94
- value: shared.getEditorValue(this.editor, this.valueFormat)
103
+ value: this.lastEmitted
95
104
  });
96
- };
105
+ }, this.changeDelay == null ? 120 : this.changeDelay);
106
+
107
+ this.changeHandler = () => { this.emitChange(); };
97
108
 
98
109
  this.editor.attachEvent("change", this.changeHandler);
99
110
  this.ready.emit(this.editor);
@@ -114,6 +125,8 @@ export class RichTextEditorAngularComponent implements AfterViewInit, OnChanges,
114
125
 
115
126
  ngOnDestroy(): void {
116
127
  if (this.editor) {
128
+ // Flush first, or the last keystroke before teardown is lost.
129
+ if (this.emitChange) { this.emitChange.flush(); }
117
130
  if (this.changeHandler && typeof this.editor.detachEvent === "function") {
118
131
  this.editor.detachEvent("change", this.changeHandler);
119
132
  }
@@ -5141,6 +5141,32 @@ function getEditorValue(editor, format) {
5141
5141
  return format === "json" ? editor.getJSON() : editor.getHTMLCode();
5142
5142
  }
5143
5143
 
5144
+ // Coalesce the editor's `change` bursts before serialising the document.
5145
+ //
5146
+ // The editor fires `change` once per keystroke, and every wrapper responded by
5147
+ // calling getHTMLCode() -- serialising the WHOLE document. Measured on a
5148
+ // 3000-paragraph (134 KB) document that is 78 ms per keypress, and in controlled
5149
+ // mode it happens twice per keystroke (once to emit, once for the value-sync
5150
+ // comparison), so typing falls behind the keyboard.
5151
+ //
5152
+ // setTimeout, not requestAnimationFrame: rAF stops firing in a background tab,
5153
+ // which would strand the last edit unsent.
5154
+ function createChangeCoalescer(emit, delay) {
5155
+ var timer = null;
5156
+ var wrapped = function () {
5157
+ if (timer) { clearTimeout(timer); }
5158
+ timer = setTimeout(function () { timer = null; emit(); }, delay);
5159
+ };
5160
+ // Callers MUST flush on teardown, or the final keystroke before unmount is lost.
5161
+ wrapped.flush = function () {
5162
+ if (timer) { clearTimeout(timer); timer = null; emit(); }
5163
+ };
5164
+ wrapped.cancel = function () {
5165
+ if (timer) { clearTimeout(timer); timer = null; }
5166
+ };
5167
+ return wrapped;
5168
+ }
5169
+
5144
5170
  function setEditorValue(editor, value, format) {
5145
5171
  if (format === "json") {
5146
5172
  editor.setJSON(value);
@@ -5176,6 +5202,7 @@ module.exports = {
5176
5202
  serializeStructuredContent: serializeStructuredContent,
5177
5203
  setDocumentPageSetup: setDocumentPageSetup,
5178
5204
  setEditorValue: setEditorValue,
5205
+ createChangeCoalescer: createChangeCoalescer,
5179
5206
  syncTableOfContents: syncTableOfContents,
5180
5207
  toMarkdown: toMarkdown,
5181
5208
  validateStructuredContent: validateStructuredContent
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.6.3",
6
+ "version": "2.6.4",
7
7
  "description": "A full-featured JavaScript rich text editor (WYSIWYG) with toolbar, plugins, i18n support, a built-in AI Toolkit (Ask AI, AI Chat, AI Review drawer, BYOK resolver), structured content APIs (JSON/Markdown), and an optional collaboration / review stack: slash commands, @mentions, human-author Track Changes, threaded comments, revision history, and Yjs-backed presence. Ships first-class React, Vue, Angular and Svelte bindings, a headless \"bring your own UI\" core, and server-safe conversion helpers.",
8
8
  "main": "./richtexteditor/rte.js",
9
9
  "bin": {
package/react/index.d.ts CHANGED
@@ -73,6 +73,13 @@ export interface RichTextEditorReactProps
73
73
  onReady?: (editor: RichTextEditorInstance) => void;
74
74
  value?: RichTextEditorStructuredInput;
75
75
  valueFormat?: RichTextEditorValueFormat;
76
+ /**
77
+ * Quiet period in ms before the document is serialised and `onChange` fires.
78
+ * The editor emits `change` on every keystroke and serialising a large
79
+ * document is not free, so changes are coalesced. Defaults to 120.
80
+ * Set to 0 to emit synchronously on every change.
81
+ */
82
+ changeDelay?: number;
76
83
  }
77
84
 
78
85
  export const RichTextEditorReact: React.ForwardRefExoticComponent<
package/react/index.js CHANGED
@@ -112,6 +112,10 @@ var RichTextEditorReact = React.forwardRef(function RichTextEditorReact(props, f
112
112
  var hostRef = React.useRef(null);
113
113
  var editorRef = React.useRef(null);
114
114
  var onChangeRef = React.useRef(props.onChange);
115
+ var lastEmittedRef = React.useRef(undefined);
116
+ // 0 disables coalescing and restores the pre-2.6 behaviour of emitting
117
+ // synchronously on every change.
118
+ var changeDelayRef = React.useRef(props.changeDelay == null ? 120 : props.changeDelay);
115
119
  var onReadyRef = React.useRef(props.onReady);
116
120
  var onErrorRef = React.useRef(props.onError);
117
121
  var formatRef = React.useRef(props.valueFormat || "html");
@@ -132,6 +136,7 @@ var RichTextEditorReact = React.forwardRef(function RichTextEditorReact(props, f
132
136
  React.useEffect(function () {
133
137
  var disposed = false;
134
138
  var changeHandler = null;
139
+ var emitChange = null;
135
140
  var activeHost = hostRef.current;
136
141
 
137
142
  var readyPromise = props.autoload === false
@@ -154,11 +159,19 @@ var RichTextEditorReact = React.forwardRef(function RichTextEditorReact(props, f
154
159
  shared.setEditorValue(editor, initialValue, formatRef.current);
155
160
  }
156
161
 
157
- changeHandler = function () {
162
+ // Serialise at most once per quiet period rather than once per
163
+ // keystroke. lastEmittedRef lets the value-sync effect below skip a
164
+ // SECOND full serialisation when the parent hands back exactly what
165
+ // we just emitted, which is the common controlled-component case.
166
+ emitChange = shared.createChangeCoalescer(function () {
167
+ var value = shared.getEditorValue(editor, formatRef.current);
168
+ lastEmittedRef.current = value;
158
169
  if (onChangeRef.current) {
159
- onChangeRef.current(shared.getEditorValue(editor, formatRef.current), editor);
170
+ onChangeRef.current(value, editor);
160
171
  }
161
- };
172
+ }, changeDelayRef.current);
173
+
174
+ changeHandler = function () { emitChange(); };
162
175
 
163
176
  editor.attachEvent("change", changeHandler);
164
177
 
@@ -174,6 +187,8 @@ var RichTextEditorReact = React.forwardRef(function RichTextEditorReact(props, f
174
187
 
175
188
  return function () {
176
189
  disposed = true;
190
+ // Flush before teardown or the last keystroke never reaches onChange.
191
+ if (emitChange) { emitChange.flush(); }
177
192
  var ed = editorRef.current;
178
193
  if (ed) {
179
194
  if (changeHandler && typeof ed.detachEvent === "function") {
@@ -198,6 +213,13 @@ var RichTextEditorReact = React.forwardRef(function RichTextEditorReact(props, f
198
213
  }
199
214
 
200
215
  var nextHtml = shared.normalizeStructuredContent(props.value);
216
+ // Fast path: the parent handed back exactly what we last emitted, which is
217
+ // what a controlled component does on every keystroke. Comparing against the
218
+ // remembered string avoids a second full getHTMLCode() of the document.
219
+ if (lastEmittedRef.current !== undefined &&
220
+ shared.normalizeStructuredContent(lastEmittedRef.current) === nextHtml) {
221
+ return;
222
+ }
201
223
  if (editorRef.current.getHTMLCode() !== nextHtml) {
202
224
  shared.setEditorValue(editorRef.current, props.value, props.valueFormat || "html");
203
225
  }
@@ -1,5 +1,92 @@
1
1
  # RichTextEditor Changelog
2
2
 
3
+ ## Version 2.6.4 — core 2.5.7: undo, IME, sanitizer, collaboration and accessibility fixes
4
+
5
+ > Core bumped to 2.5.7. The earlier build of this work was labelled 2.5.6, which is
6
+ > already published (protected rte.js 564,563). Two different builds must not share a
7
+ > version number, least of all on npm and NuGet where a version cannot be reused or
8
+ > corrected after the fact.
9
+ >
10
+ > npm was the last channel to receive this build. The website, the customer download
11
+ > zips and NuGet (RichTextBox 1.0.8) were already serving core 2.5.7 / protected
12
+ > rte.js 565,112 while npm was still on 2.6.3 / core 2.5.6 — so npm users did not have
13
+ > the undo fix below, which looks exactly like data loss. Verified byte-identical to
14
+ > the live site before publishing.
15
+
16
+ ### One Ctrl+Z after loading a document blanked it
17
+ `setHTMLCode()` did not seed the undo baseline, so the history baseline stayed the *empty* document and the last available undo discarded the loaded content instead of returning to it. Every framework wrapper was affected, because React, Vue and Angular all load through `setEditorValue()` -> `setHTMLCode()`. Load a document, type one character, press Ctrl+Z, and the document went blank. Redo recovered it, but it looked exactly like data loss on the most common keystroke in an editor. Typing granularity is unchanged; with two typing bursts the first undo always behaved correctly, which is why the bug hid behind however many edits you happened to make. (core rte.js)
18
+
19
+ ### IME composition fired a change event per phonetic keystroke
20
+ There was no `compositionstart`/`compositionend` handling anywhere — not in the core, not in any wrapper. Composing 你好 produced 4 change events instead of 1, and intermediate phonetic buffers could become undo steps. Every `change` makes the framework wrappers serialise the whole document, and a controlled component that writes the value back mid-composition tears down the IME buffer. Now 0 events while composing and 1 on commit. (core rte.js)
21
+
22
+ ### Framework wrappers serialised the whole document on every keystroke
23
+ React, Vue and Angular each called `getEditorValue()` — a full `getHTMLCode()` — inside the `change` handler with no coalescing, and in controlled mode a second time for the value comparison. Measured on a 3,000-paragraph (134 KB) document, typing 19 characters cost 19 serialisations and 1,735 ms; it now costs 1 and 90 ms. New optional `changeDelay` prop (default 120 ms; 0 restores the old synchronous behaviour).
24
+
25
+ ### Unquoted attribute values had their first character duplicated
26
+ The paste/import parser passed the value's *first character* as the quote and the *rest* as the value, then wrapped the value in that "quote": `class=hello` became `class="helloh"`, `title=abc` became `title="abca"` (visibly wrong tooltip text), and single-character values vanished — `cellpadding=0` was dropped entirely. Unquoted attributes are legal HTML and are what Word emits, which is also why Word's `class=MsoNormal` survived the Mso filter as `MsoNormalM` and leaked into saved content. (core rte.js)
27
+
28
+ ### HTML comments are preserved
29
+ `sanitizer.js` deleted every comment, justified only by the remark "comments can hide markup". That is false — serialise `<!--<script>alert(1)</script>-->`, re-parse it, and the script is inert. What is genuinely unsafe is a small enumerable set of comment *data* that cannot survive serialisation (containing `-->` or `--!>`, or starting with `>` or `->`), which breaks out on re-parse; only those are dropped now. This restores CMS marker round-tripping and Outlook conditional comments, the mechanism `emailtoolkit.js` needs. The live-DOM MutationObserver had to change too: it skipped every non-element node, which was safe only while comments were being deleted unconditionally.
30
+
31
+ ### Tag and attribute allowlists accept patterns
32
+ `sanitizerAllowTags` / `sanitizerAllowAttributes` now take `"x-*"` wildcards and RegExp, not just exact names — the difference between a usable General-HTML-Support equivalent and one that requires an integrator to enumerate every element a CMS might emit. Wildcards are matched by hand rather than compiled to a RegExp, so a config value can never be a regex-injection vector. A pattern widens what is kept, never what executes.
33
+
34
+ ### Split-button dropdown arrows failed WCAG 2.2 target size
35
+ The dropdown half of a split button was 14x24 CSS px, failing SC 2.5.8 Target Size (Minimum), Level AA, which requires 24x24; the spacing exception cannot apply because the two halves are adjacent by construction. Both the arrow and its container were widened (42 -> 52 px), because widening the arrow alone made it worse — the fixed-width flex row shrank both halves to 19 px. Split buttons are now 48 px wide instead of 38 px. SC 2.5.5 (44 px, Level AAA) is unchanged and not claimed.
36
+
37
+ Found by running features end-to-end on the source build; every item has a regression test in RTEJSWeb/_tests.
38
+
39
+ ### 16 slash commands were unreachable
40
+ Plugins that sort before slashcommand.js registered their slash entries before the registry existed and were silently dropped (`/import document`, `/readability stats`), and slashcommand.js evaluated its own feature gates once at init, before later-sorting plugins defined their APIs (sort table asc/desc, RTL/LTR/auto direction, watermark, table of contents, typewriter, focus mode). Defaults are now refreshed after all plugins initialise and when the menu opens; entries for cross-reference, restricted editing and dictation (opt-in) were added. Menu: 68 -> 82.
41
+
42
+ ### The slash menu is now localizable
43
+ Every entry (title, description, section) resolves through the language packs with an English fallback. zh-Hans, zh-Hant and de packs ship the keys (titles, sections, descriptions for Chinese); the plugin toolbar tooltips that stayed English (accessibility checker, comments, minimap, outline, AI assist, text-part language) are covered too.
44
+
45
+ ### Ctrl+F opened a different Find dialog than the toolbar button
46
+ Ctrl+F and `editor.openFindDialog()` now open the same localized Find & Replace panel as the toolbar command; the hard-coded-English dialog remains only as a fallback. (core rte.js — ships with the next protected build)
47
+
48
+ ### Shadow DOM: an inline theme stylesheet was not adopted
49
+ A theme injected as an inline `<style>` (Vite / webpack style-loader) left a shadow-root editor unstyled (39,726 px toolbar). Inline styles are now recognised by content.
50
+
51
+ ### `<rich-text-editor>` child markup
52
+ Markup written between the tags becomes the initial value when no `value` attribute is set (like `<textarea>`) and is consumed either way instead of rendering beside the editor.
53
+
54
+ ### Real-time collaboration: text sync through a relay was broken on the documented path
55
+ With `collabTextSync: "crdt"`, a `Y.Doc` built by your own Yjs (npm / esm `yjs` + `y-websocket`, exactly as the docs show) could not receive the engine's nodes — presence worked and the status reported `crdt`, but text never reached other peers. The plugin now bridges such a doc into the engine's Yjs and relays updates both ways; pass your module as `attach({ Y })` (the docs' snippets do). Without it the plugin fails loudly with a `fallbackReason` instead of silently claiming to sync. Regression test: `_tests/collab-relay-sync.test.js` (two real browser pages through a WebSocket relay).
56
+
57
+ ### CRDT engine: comment anchors, mentions and tracked changes now travel to peers
58
+ In `crdt` text-sync mode, inline elements that are not formatting (comment anchors, @mention chips, tracked-change spans, footnote references) folded into plain text inside the Yjs document, so collaborators never received them and any re-render dropped them locally. The engine now carries such elements as a passthrough mark (tag + attributes) and renders them back as one element with formatting nested inside.
59
+
60
+ ### CRDT engine: a local edit could be wiped by a same-tick document update
61
+ The engine re-rendered from Yjs on any non-local update and discarded not-yet-flushed local DOM mutations; writing a comment's ledger entry right after inserting its anchor erased the anchor. Pending local mutations are now committed before a re-render, and the plugin's own ledger writes no longer trigger one.
62
+
63
+ ### comments.add accepts a DOM Range
64
+ `editor.comments.add({ text, anchor: range })` now highlights the range; previously only an anchor object with a `text` field did, so a plain Range silently produced a comment without a highlight.
65
+
66
+ ### CRDT engine: concurrent edits to one paragraph no longer duplicate it
67
+ Two collaborators typing in the same paragraph at the same time used to converge to both versions of the paragraph body. The engine now applies minimal text edits (common prefix/suffix) so concurrent typing merges character by character.
68
+
69
+ ### Undo in collaborative sessions is now scoped to your own edits
70
+ With `collabTextSync: "crdt"`, Ctrl+Z ran the editor's snapshot undo and could revert other people's changes on every peer. Undo/redo now use the engine's per-author undo manager while collaboration is active.
71
+
72
+ ### insertcode: storage access guarded
73
+ `sessionStorage` reads/writes are wrapped, so the command no longer throws in sandboxed or opaque-origin embeds.
74
+
75
+ ## Version 2.6.3 — Markdown task lists + escaped-character fixes (core 2.5.6)
76
+
77
+ More markdown round-trip completeness (core rte.js 2.5.5→2.5.6):
78
+ - GFM task lists now round-trip: a todo item (rte-task-item / data-checked) exports as "- [x]"/"- [ ]" and imports back to the same checkbox markup (nested tasks included). A normal list stays a normal list.
79
+ - Backslash-escaped markdown characters (* _ [ ) imported wrong — they were parsed as formatting and leaked NUL bytes into the output. They now import as the literal character.
80
+ - A literal < or > in text round-tripped as &lt; ; the exporter no longer backslash-escapes angle brackets (the importer already escapes them safely).
81
+
82
+ ## Version 2.6.2 — Markdown and .docx round-trip fixes (core 2.5.5)
83
+
84
+ Round-trip fixes found by dogfooding export→import (core rte.js 2.5.4→2.5.5):
85
+ - Markdown import threw on any link (URL sanitizer out of scope) — fixed.
86
+ - Markdown GFM tables + indented nested lists did not import (became paragraphs) — now parsed (table header → <th>; nested via an indentation stack).
87
+ - Inline code was double-escaped — fixed.
88
+ - .docx table headers (<w:tblHeader/>) and blockquotes (Quote style) dropped on import — now <th>/<blockquote>.
89
+
3
90
  ## Version 2.6.0 — Layout tables, crash recovery, shadow DOM, web component (Pass 69)
4
91
 
5
92
  > `rte.js` is unchanged from 2.5.4 — every item below is a plugin or a companion