@notectl/angular 2.0.1 → 2.0.3

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.
@@ -1,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, inject, DestroyRef, input, model, output, signal, computed, viewChild, afterNextRender, effect, ChangeDetectionStrategy, Component, forwardRef, Directive, Injectable } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, DestroyRef, input, model, output, signal, computed, viewChild, afterNextRender, effect, forwardRef, ChangeDetectionStrategy, Component, Directive, Injectable } from '@angular/core';
3
+ import { NG_VALUE_ACCESSOR } from '@angular/forms';
3
4
  import { ThemePreset, NotectlEditor } from '@notectl/core';
4
5
  export { DARK_THEME, LIGHT_THEME, ThemePreset, createTheme } from '@notectl/core';
5
- import { NG_VALUE_ACCESSOR } from '@angular/forms';
6
6
  import { Subject } from 'rxjs';
7
7
  export { STARTER_FONTS } from '@notectl/core/fonts';
8
8
  export { TextFormattingPlugin } from '@notectl/core/plugins/text-formatting';
@@ -25,6 +25,142 @@ export { TextDirectionPlugin } from '@notectl/core/plugins/text-direction';
25
25
  export { SuperSubPlugin } from '@notectl/core/plugins/super-sub';
26
26
  export { ToolbarPlugin } from '@notectl/core/plugins/toolbar';
27
27
 
28
+ const EMPTY_HTML = '<p></p>';
29
+ /** Escapes HTML special characters before inserting plain text. */
30
+ function escapeHtml(text) {
31
+ return text
32
+ .replace(/&/g, '&amp;')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ .replace(/"/g, '&quot;')
36
+ .replace(/'/g, '&#39;');
37
+ }
38
+ function isDocumentValue(value) {
39
+ return typeof value === 'object' && value !== null;
40
+ }
41
+ async function readEditorValue(editor, format) {
42
+ switch (format) {
43
+ case 'html':
44
+ return editor.getContentHTML();
45
+ case 'text':
46
+ return editor.getText();
47
+ default:
48
+ return editor.getJSON();
49
+ }
50
+ }
51
+ async function writeEditorValue(editor, format, value) {
52
+ if (value === null || value === '') {
53
+ await editor.setContentHTML(EMPTY_HTML);
54
+ return;
55
+ }
56
+ if (format === 'json' && isDocumentValue(value)) {
57
+ editor.setJSON(value);
58
+ return;
59
+ }
60
+ if (format === 'text' && typeof value === 'string') {
61
+ await editor.setContentHTML(`<p>${escapeHtml(value)}</p>`);
62
+ return;
63
+ }
64
+ if (typeof value === 'string') {
65
+ await editor.setContentHTML(value);
66
+ return;
67
+ }
68
+ editor.setJSON(value);
69
+ }
70
+
71
+ class EditorValueController {
72
+ options;
73
+ lastDocument;
74
+ mutedControlChanges = 0;
75
+ serializedReadVersion = 0;
76
+ writeQueue = Promise.resolve();
77
+ constructor(options) {
78
+ this.options = options;
79
+ }
80
+ reset() {
81
+ this.lastDocument = undefined;
82
+ this.serializedReadVersion++;
83
+ }
84
+ handleEditorStateChange(doc) {
85
+ this.lastDocument = doc;
86
+ this.options.updateContent(doc);
87
+ if (this.mutedControlChanges > 0)
88
+ return;
89
+ const readVersion = ++this.serializedReadVersion;
90
+ void this.readCurrentValue().then((value) => {
91
+ if (readVersion !== this.serializedReadVersion)
92
+ return;
93
+ this.options.emitControlValue(value);
94
+ });
95
+ }
96
+ syncExternalContent(doc) {
97
+ if (doc === this.lastDocument)
98
+ return;
99
+ this.lastDocument = doc;
100
+ void this.enqueueSilent(async (editor) => {
101
+ editor.setJSON(doc);
102
+ });
103
+ }
104
+ writeControlValue(value) {
105
+ void this.enqueueSilent(async (editor) => {
106
+ await writeEditorValue(editor, this.options.getFormat(), value);
107
+ });
108
+ }
109
+ setDocument(doc) {
110
+ this.lastDocument = doc;
111
+ return this.enqueueInteractive(async (editor) => {
112
+ editor.setJSON(doc);
113
+ });
114
+ }
115
+ setSerializedValue(value) {
116
+ return this.enqueueInteractive(async (editor) => {
117
+ await writeEditorValue(editor, this.options.getFormat(), value);
118
+ });
119
+ }
120
+ applyInitialDocument(editor, doc) {
121
+ this.lastDocument = doc;
122
+ return this.runSilent(async () => {
123
+ editor.setJSON(doc);
124
+ });
125
+ }
126
+ async readCurrentValue() {
127
+ const editor = this.options.getEditor();
128
+ if (!editor)
129
+ return null;
130
+ return readEditorValue(editor, this.options.getFormat());
131
+ }
132
+ enqueueSilent(task) {
133
+ return this.enqueue(task, true);
134
+ }
135
+ enqueueInteractive(task) {
136
+ return this.enqueue(task, false);
137
+ }
138
+ enqueue(task, silent) {
139
+ const next = this.writeQueue.then(async () => {
140
+ await this.options.whenReady();
141
+ const editor = this.options.getEditor();
142
+ if (!editor)
143
+ return;
144
+ if (silent) {
145
+ await this.runSilent(() => task(editor));
146
+ return;
147
+ }
148
+ await task(editor);
149
+ });
150
+ this.writeQueue = next.catch(() => undefined);
151
+ return next;
152
+ }
153
+ async runSilent(task) {
154
+ this.mutedControlChanges++;
155
+ try {
156
+ await task();
157
+ }
158
+ finally {
159
+ this.mutedControlChanges--;
160
+ }
161
+ }
162
+ }
163
+
28
164
  /**
29
165
  * Default configuration applied to all `<notectl-editor>` instances within
30
166
  * the injector scope. Component-level inputs override these defaults.
@@ -65,59 +201,38 @@ function provideNotectl(options = {}) {
65
201
  return makeEnvironmentProviders(providers);
66
202
  }
67
203
 
68
- /**
69
- * Angular standalone component wrapping the `<ntl-editor>` Web Component.
70
- *
71
- * Uses `afterNextRender()` for SSR-safe initialization and `effect()` for
72
- * reactive input tracking — no lifecycle interfaces needed.
73
- *
74
- * @example
75
- * ```html
76
- * <!-- Basic usage -->
77
- * <ntl-editor [toolbar]="toolbar" [plugins]="plugins" />
78
- *
79
- * <!-- Two-way content binding -->
80
- * <ntl-editor [(content)]="myDocument" [toolbar]="toolbar" />
81
- *
82
- * <!-- Reactive forms -->
83
- * <ntl-editor [formControl]="editorControl" [toolbar]="toolbar" />
84
- * ```
85
- */
204
+ function initConfigEquals(a, b) {
205
+ return (a.plugins === b.plugins &&
206
+ a.toolbar === b.toolbar &&
207
+ a.features === b.features &&
208
+ a.autofocus === b.autofocus &&
209
+ a.maxHistoryDepth === b.maxHistoryDepth &&
210
+ a.locale === b.locale &&
211
+ a.styleNonce === b.styleNonce);
212
+ }
86
213
  class NotectlEditorComponent {
87
- // --- Injected dependencies ---
88
214
  destroyRef = inject(DestroyRef);
89
215
  defaultConfig = inject(NOTECTL_DEFAULT_CONFIG, { optional: true });
90
- // --- Signal Inputs (1:1 with Web Component config) ---
91
- plugins = input([], ...(ngDevMode ? [{ debugName: "plugins" }] : []));
92
- toolbar = input(...(ngDevMode ? [undefined, { debugName: "toolbar" }] : []));
93
- features = input(...(ngDevMode ? [undefined, { debugName: "features" }] : []));
94
- placeholder = input('Start typing...', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
95
- readonlyMode = input(false, ...(ngDevMode ? [{ debugName: "readonlyMode" }] : []));
96
- autofocus = input(false, ...(ngDevMode ? [{ debugName: "autofocus" }] : []));
97
- maxHistoryDepth = input(...(ngDevMode ? [undefined, { debugName: "maxHistoryDepth" }] : []));
98
- theme = input(ThemePreset.Light, ...(ngDevMode ? [{ debugName: "theme" }] : []));
99
- // --- Two-way content binding via model() ---
100
- /**
101
- * Two-way bindable document content.
102
- *
103
- * `undefined` means no external content was provided the editor manages
104
- * its own state. Once the editor emits changes, the model updates to the
105
- * current `Document`.
106
- *
107
- * @example
108
- * ```html
109
- * <ntl-editor [(content)]="myDocument" />
110
- * ```
111
- */
112
- content = model(...(ngDevMode ? [undefined, { debugName: "content" }] : []));
113
- // --- Signal Outputs (events bridged from Web Component) ---
216
+ contentFormat = inject(NOTECTL_CONTENT_FORMAT, { optional: true }) ?? 'json';
217
+ plugins = input(undefined, ...(ngDevMode ? [{ debugName: "plugins" }] : /* istanbul ignore next */ []));
218
+ toolbar = input(undefined, ...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
219
+ features = input(undefined, ...(ngDevMode ? [{ debugName: "features" }] : /* istanbul ignore next */ []));
220
+ placeholder = input(undefined, ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
221
+ readonlyMode = input(undefined, ...(ngDevMode ? [{ debugName: "readonlyMode" }] : /* istanbul ignore next */ []));
222
+ autofocus = input(undefined, ...(ngDevMode ? [{ debugName: "autofocus" }] : /* istanbul ignore next */ []));
223
+ maxHistoryDepth = input(undefined, ...(ngDevMode ? [{ debugName: "maxHistoryDepth" }] : /* istanbul ignore next */ []));
224
+ theme = input(undefined, ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
225
+ paperSize = input(undefined, ...(ngDevMode ? [{ debugName: "paperSize" }] : /* istanbul ignore next */ []));
226
+ dir = input(undefined, ...(ngDevMode ? [{ debugName: "dir" }] : /* istanbul ignore next */ []));
227
+ locale = input(undefined, ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
228
+ styleNonce = input(undefined, ...(ngDevMode ? [{ debugName: "styleNonce" }] : /* istanbul ignore next */ []));
229
+ content = model(undefined, ...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
114
230
  stateChange = output();
115
231
  selectionChange = output();
116
232
  editorFocus = output();
117
233
  editorBlur = output();
118
234
  ready = output();
119
- // --- Reactive State ---
120
- editorState = signal(null, ...(ngDevMode ? [{ debugName: "editorState" }] : []));
235
+ editorState = signal(null, ...(ngDevMode ? [{ debugName: "editorState" }] : /* istanbul ignore next */ []));
121
236
  isEmpty = computed(() => {
122
237
  const state = this.editorState();
123
238
  if (!state)
@@ -131,126 +246,147 @@ class NotectlEditorComponent {
131
246
  if (!block)
132
247
  return true;
133
248
  return block.type === 'paragraph' && block.children.length === 0;
134
- }, ...(ngDevMode ? [{ debugName: "isEmpty" }] : []));
135
- // --- Internal state ---
249
+ }, ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
250
+ resolvedPlugins = computed(() => this.plugins() ?? this.defaultConfig?.plugins ?? [], ...(ngDevMode ? [{ debugName: "resolvedPlugins" }] : /* istanbul ignore next */ []));
251
+ resolvedToolbar = computed(() => this.toolbar() ?? this.defaultConfig?.toolbar, ...(ngDevMode ? [{ debugName: "resolvedToolbar" }] : /* istanbul ignore next */ []));
252
+ resolvedFeatures = computed(() => this.features() ?? this.defaultConfig?.features, ...(ngDevMode ? [{ debugName: "resolvedFeatures" }] : /* istanbul ignore next */ []));
253
+ resolvedPlaceholder = computed(() => this.placeholder() ?? this.defaultConfig?.placeholder ?? 'Start typing...', ...(ngDevMode ? [{ debugName: "resolvedPlaceholder" }] : /* istanbul ignore next */ []));
254
+ resolvedReadonlyMode = computed(() => this.readonlyMode() ?? this.defaultConfig?.readonly ?? false, ...(ngDevMode ? [{ debugName: "resolvedReadonlyMode" }] : /* istanbul ignore next */ []));
255
+ resolvedAutofocus = computed(() => this.autofocus() ?? this.defaultConfig?.autofocus ?? false, ...(ngDevMode ? [{ debugName: "resolvedAutofocus" }] : /* istanbul ignore next */ []));
256
+ resolvedMaxHistoryDepth = computed(() => this.maxHistoryDepth() ?? this.defaultConfig?.maxHistoryDepth, ...(ngDevMode ? [{ debugName: "resolvedMaxHistoryDepth" }] : /* istanbul ignore next */ []));
257
+ resolvedTheme = computed(() => this.theme() ?? this.defaultConfig?.theme ?? ThemePreset.Light, ...(ngDevMode ? [{ debugName: "resolvedTheme" }] : /* istanbul ignore next */ []));
258
+ resolvedPaperSize = computed(() => this.paperSize() ?? this.defaultConfig?.paperSize, ...(ngDevMode ? [{ debugName: "resolvedPaperSize" }] : /* istanbul ignore next */ []));
259
+ resolvedDir = computed(() => this.dir() ?? this.defaultConfig?.dir, ...(ngDevMode ? [{ debugName: "resolvedDir" }] : /* istanbul ignore next */ []));
260
+ resolvedLocale = computed(() => this.locale() ?? this.defaultConfig?.locale, ...(ngDevMode ? [{ debugName: "resolvedLocale" }] : /* istanbul ignore next */ []));
261
+ resolvedStyleNonce = computed(() => this.styleNonce() ?? this.defaultConfig?.styleNonce, ...(ngDevMode ? [{ debugName: "resolvedStyleNonce" }] : /* istanbul ignore next */ []));
262
+ effectiveReadonly = computed(() => this.disabledByForms() || this.resolvedReadonlyMode(), ...(ngDevMode ? [{ debugName: "effectiveReadonly" }] : /* istanbul ignore next */ []));
136
263
  hostRef = viewChild.required('host');
264
+ initialized = signal(false, ...(ngDevMode ? [{ debugName: "initialized" }] : /* istanbul ignore next */ []));
265
+ disabledByForms = signal(false, ...(ngDevMode ? [{ debugName: "disabledByForms" }] : /* istanbul ignore next */ []));
266
+ valueController = new EditorValueController({
267
+ emitControlValue: (value) => this.onChange(value),
268
+ getEditor: () => this.editorRef,
269
+ getFormat: () => this.contentFormat,
270
+ updateContent: (doc) => this.content.set(doc),
271
+ whenReady: () => this.readyPromise,
272
+ });
137
273
  editorRef = null;
138
274
  readyResolve = null;
139
- readyPromise = new Promise((resolve) => {
140
- this.readyResolve = resolve;
141
- });
142
- initialized = signal(false, ...(ngDevMode ? [{ debugName: "initialized" }] : []));
143
- /** Tracks the last document set from within the editor to prevent feedback loops. */
144
- lastEditorDoc = null;
275
+ readyPromise;
276
+ lastInitConfig = null;
277
+ queuedInitConfig = null;
278
+ reinitializePromise = null;
279
+ pendingInitialDocument;
280
+ onChange = () => { };
281
+ onTouched = () => { };
145
282
  constructor() {
146
- // SSR-safe: only runs in the browser after first render
283
+ this.resetReadyPromise();
147
284
  afterNextRender(() => {
148
- this.initEditor();
285
+ this.initEditor(this.captureInitConfig());
149
286
  });
150
- // React to input changes via effect() — replaces ngOnChanges
151
287
  effect(() => {
152
- const currentTheme = this.theme();
153
- const currentPlaceholder = this.placeholder();
154
- const currentReadonly = this.readonlyMode();
155
288
  const editor = this.editorRef;
156
289
  if (!this.initialized() || !editor)
157
290
  return;
158
- editor.setTheme(currentTheme);
291
+ editor.setTheme(this.resolvedTheme());
159
292
  editor.configure({
160
- placeholder: currentPlaceholder,
161
- readonly: currentReadonly,
293
+ dir: this.resolvedDir(),
294
+ paperSize: this.resolvedPaperSize(),
295
+ placeholder: this.resolvedPlaceholder(),
296
+ readonly: this.effectiveReadonly(),
162
297
  });
163
298
  });
164
- // Sync external content model changes into the editor.
165
- // Skips when the document was set by the editor itself (feedback loop prevention).
166
299
  effect(() => {
167
300
  const doc = this.content();
168
- const editor = this.editorRef;
169
- if (!this.initialized() || !editor || !doc)
170
- return;
171
- // Skip if this document originated from the editor's own state change
172
- if (doc === this.lastEditorDoc)
301
+ if (!doc)
173
302
  return;
174
- editor.setJSON(doc);
303
+ this.valueController.syncExternalContent(doc);
304
+ });
305
+ effect(() => {
306
+ this.scheduleReinitialization(this.captureInitConfig());
175
307
  });
176
308
  this.destroyRef.onDestroy(() => {
177
- this.destroyEditor();
309
+ void this.destroyEditor();
178
310
  });
179
311
  }
180
- // --- Public API (proxy to Web Component) ---
181
- /** Returns the document as JSON. */
182
312
  getJSON() {
183
313
  return this.requireEditor().getJSON();
184
314
  }
185
- /** Sets the document from JSON. */
186
315
  setJSON(doc) {
187
- this.requireEditor().setJSON(doc);
316
+ void this.valueController.setDocument(doc);
188
317
  }
189
- /** Returns sanitized HTML representation of the document. */
190
318
  async getContentHTML() {
191
319
  return this.requireEditor().getContentHTML();
192
320
  }
193
- /** Sets content from HTML (sanitized). */
194
321
  async setContentHTML(html) {
195
- return this.requireEditor().setContentHTML(html);
322
+ await this.valueController.setSerializedValue(html);
196
323
  }
197
- /** Returns plain text content. */
198
324
  getText() {
199
325
  return this.requireEditor().getText();
200
326
  }
201
- /** Proxy to the Web Component's `commands` object. */
202
327
  get commands() {
203
328
  return this.requireEditor().commands;
204
329
  }
205
- /** Proxy to the Web Component's `can()` capability checker. */
206
330
  can() {
207
331
  return this.requireEditor().can();
208
332
  }
209
- /** Executes a named command registered by a plugin. */
210
333
  executeCommand(name) {
211
- if (!this.editorRef)
212
- return false;
213
- return this.editorRef.executeCommand(name);
334
+ return this.editorRef?.executeCommand(name) ?? false;
214
335
  }
215
- /** Configures a plugin at runtime. */
216
336
  configurePlugin(pluginId, config) {
217
337
  this.editorRef?.configurePlugin(pluginId, config);
218
338
  }
219
- /** Dispatches a transaction. */
220
339
  dispatch(tr) {
221
340
  this.editorRef?.dispatch(tr);
222
341
  }
223
- /** Returns the current editor state. */
224
342
  getState() {
225
343
  return this.requireEditor().getState();
226
344
  }
227
- /** Changes the theme at runtime. */
228
345
  setTheme(theme) {
229
346
  this.editorRef?.setTheme(theme);
230
347
  }
231
- /** Returns the current theme setting. */
232
348
  getTheme() {
233
- return this.editorRef?.getTheme() ?? this.theme();
349
+ return this.editorRef?.getTheme() ?? this.resolvedTheme();
234
350
  }
235
- /** Sets the readonly state programmatically (used by ControlValueAccessor). */
236
351
  setReadonly(readonlyState) {
237
352
  this.editorRef?.configure({ readonly: readonlyState });
238
353
  }
239
- /** Resolves when the editor is ready. */
240
354
  whenReady() {
241
355
  return this.readyPromise;
242
356
  }
243
- // --- Private ---
244
- initEditor() {
357
+ focus(options) {
358
+ const editor = this.editorRef;
359
+ if (!editor)
360
+ return;
361
+ const focusTarget = editor.shadowRoot?.querySelector('[contenteditable]') ?? editor;
362
+ focusTarget.focus(options);
363
+ }
364
+ writeValue(value) {
365
+ this.valueController.writeControlValue(value);
366
+ }
367
+ registerOnChange(fn) {
368
+ this.onChange = fn;
369
+ }
370
+ registerOnTouched(fn) {
371
+ this.onTouched = fn;
372
+ }
373
+ setDisabledState(isDisabled) {
374
+ this.disabledByForms.set(isDisabled);
375
+ }
376
+ resetReadyPromise() {
377
+ this.readyPromise = new Promise((resolve) => {
378
+ this.readyResolve = resolve;
379
+ });
380
+ }
381
+ initEditor(snapshot) {
245
382
  const hostElement = this.hostRef().nativeElement;
246
- const config = this.buildConfig();
247
383
  const editor = new NotectlEditor();
248
384
  this.editorRef = editor;
249
- // Register event listeners BEFORE appending to DOM, because
250
- // appendChild triggers connectedCallback → init() synchronously.
385
+ this.lastInitConfig = snapshot;
386
+ this.valueController.reset();
251
387
  editor.on('stateChange', (event) => {
252
388
  this.editorState.set(event.newState);
253
- this.syncContentModel(event.newState.doc);
389
+ this.valueController.handleEditorStateChange(event.newState.doc);
254
390
  this.stateChange.emit(event);
255
391
  });
256
392
  editor.on('selectionChange', (event) => {
@@ -260,211 +396,146 @@ class NotectlEditorComponent {
260
396
  this.editorFocus.emit();
261
397
  });
262
398
  editor.on('blur', () => {
399
+ this.onTouched();
263
400
  this.editorBlur.emit();
264
401
  });
265
402
  editor.on('ready', () => {
266
403
  this.initialized.set(true);
267
- const state = editor.getState();
268
- this.editorState.set(state);
269
- // Apply initial content model if set before editor was ready
270
- const initialContent = this.content();
271
- if (initialContent) {
272
- editor.setJSON(initialContent);
273
- }
274
- this.readyResolve?.();
275
- this.ready.emit();
404
+ this.editorState.set(editor.getState());
405
+ void this.applyInitialContent(editor).finally(() => {
406
+ this.readyResolve?.();
407
+ this.ready.emit();
408
+ });
276
409
  });
277
- // Init with config BEFORE appending to DOM. appendChild triggers
278
- // connectedCallback which calls init() without config — by calling
279
- // init(config) first, the editor initializes with the full config
280
- // and connectedCallback's init() becomes a no-op (already initialized).
281
- editor.init(config);
410
+ editor.init(this.buildConfig());
282
411
  hostElement.appendChild(editor);
283
412
  }
413
+ async applyInitialContent(editor) {
414
+ const currentContent = this.content();
415
+ const initialContent = currentContent ?? this.pendingInitialDocument;
416
+ this.pendingInitialDocument = undefined;
417
+ if (!initialContent)
418
+ return;
419
+ await this.valueController.applyInitialDocument(editor, initialContent);
420
+ }
284
421
  buildConfig() {
285
- const defaults = this.defaultConfig ?? {};
286
- const toolbar = this.toolbar();
287
- const features = this.features();
288
- const maxHistory = this.maxHistoryDepth();
289
422
  const config = {
290
- ...defaults,
291
- plugins: this.plugins(),
292
- placeholder: this.placeholder(),
293
- readonly: this.readonlyMode(),
294
- autofocus: this.autofocus(),
295
- theme: this.theme(),
423
+ ...this.defaultConfig,
424
+ autofocus: this.resolvedAutofocus(),
425
+ dir: this.resolvedDir(),
426
+ locale: this.resolvedLocale(),
427
+ maxHistoryDepth: this.resolvedMaxHistoryDepth(),
428
+ paperSize: this.resolvedPaperSize(),
429
+ placeholder: this.resolvedPlaceholder(),
430
+ plugins: this.resolvedPlugins(),
431
+ readonly: this.effectiveReadonly(),
432
+ styleNonce: this.resolvedStyleNonce(),
433
+ theme: this.resolvedTheme(),
296
434
  };
435
+ const toolbar = this.resolvedToolbar();
297
436
  if (toolbar !== undefined) {
298
437
  config.toolbar = toolbar;
299
438
  }
439
+ const features = this.resolvedFeatures();
300
440
  if (features !== undefined) {
301
441
  config.features = features;
302
442
  }
303
- if (maxHistory !== undefined) {
304
- config.maxHistoryDepth = maxHistory;
305
- }
306
443
  return config;
307
444
  }
308
- /** Syncs editor document changes to the content model without feedback loops. */
309
- syncContentModel(doc) {
310
- this.lastEditorDoc = doc;
311
- this.content.set(doc);
445
+ captureInitConfig() {
446
+ return {
447
+ autofocus: this.resolvedAutofocus(),
448
+ features: this.resolvedFeatures(),
449
+ locale: this.resolvedLocale(),
450
+ maxHistoryDepth: this.resolvedMaxHistoryDepth(),
451
+ plugins: this.resolvedPlugins(),
452
+ styleNonce: this.resolvedStyleNonce(),
453
+ toolbar: this.resolvedToolbar(),
454
+ };
455
+ }
456
+ scheduleReinitialization(snapshot) {
457
+ if (!this.initialized() || !this.lastInitConfig)
458
+ return;
459
+ if (initConfigEquals(snapshot, this.lastInitConfig))
460
+ return;
461
+ this.queuedInitConfig = snapshot;
462
+ if (this.reinitializePromise)
463
+ return;
464
+ this.reinitializePromise = (async () => {
465
+ while (this.queuedInitConfig) {
466
+ const nextSnapshot = this.queuedInitConfig;
467
+ this.queuedInitConfig = null;
468
+ this.pendingInitialDocument = this.editorRef?.getJSON();
469
+ this.resetReadyPromise();
470
+ this.initialized.set(false);
471
+ await this.destroyEditor();
472
+ this.initEditor(nextSnapshot);
473
+ await this.readyPromise;
474
+ }
475
+ })().finally(() => {
476
+ this.reinitializePromise = null;
477
+ });
312
478
  }
313
- destroyEditor() {
314
- if (this.editorRef) {
315
- this.editorRef.destroy();
316
- this.editorRef = null;
479
+ async destroyEditor() {
480
+ const editor = this.editorRef;
481
+ if (!editor) {
482
+ this.initialized.set(false);
483
+ this.editorState.set(null);
484
+ return;
317
485
  }
486
+ this.editorRef = null;
487
+ editor.remove();
488
+ await editor.destroy();
318
489
  this.initialized.set(false);
490
+ this.editorState.set(null);
319
491
  }
320
492
  requireEditor() {
321
493
  const editor = this.editorRef;
322
- if (!editor) {
494
+ if (!editor || !this.initialized()) {
323
495
  throw new Error('NotectlEditor is not initialized. Await whenReady() first.');
324
496
  }
325
497
  return editor;
326
498
  }
327
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
328
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.0", type: NotectlEditorComponent, isStandalone: true, selector: "ntl-editor", inputs: { plugins: { classPropertyName: "plugins", publicName: "plugins", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null }, features: { classPropertyName: "features", publicName: "features", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, readonlyMode: { classPropertyName: "readonlyMode", publicName: "readonlyMode", isSignal: true, isRequired: false, transformFunction: null }, autofocus: { classPropertyName: "autofocus", publicName: "autofocus", isSignal: true, isRequired: false, transformFunction: null }, maxHistoryDepth: { classPropertyName: "maxHistoryDepth", publicName: "maxHistoryDepth", isSignal: true, isRequired: false, transformFunction: null }, theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { content: "contentChange", stateChange: "stateChange", selectionChange: "selectionChange", editorFocus: "editorFocus", editorBlur: "editorBlur", ready: "ready" }, viewQueries: [{ propertyName: "hostRef", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: '<div #host></div>', isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
499
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
500
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.4", type: NotectlEditorComponent, isStandalone: true, selector: "ntl-editor", inputs: { plugins: { classPropertyName: "plugins", publicName: "plugins", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null }, features: { classPropertyName: "features", publicName: "features", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, readonlyMode: { classPropertyName: "readonlyMode", publicName: "readonlyMode", isSignal: true, isRequired: false, transformFunction: null }, autofocus: { classPropertyName: "autofocus", publicName: "autofocus", isSignal: true, isRequired: false, transformFunction: null }, maxHistoryDepth: { classPropertyName: "maxHistoryDepth", publicName: "maxHistoryDepth", isSignal: true, isRequired: false, transformFunction: null }, theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, paperSize: { classPropertyName: "paperSize", publicName: "paperSize", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, styleNonce: { classPropertyName: "styleNonce", publicName: "styleNonce", isSignal: true, isRequired: false, transformFunction: null }, content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { content: "contentChange", stateChange: "stateChange", selectionChange: "selectionChange", editorFocus: "editorFocus", editorBlur: "editorBlur", ready: "ready" }, host: { properties: { "attr.aria-disabled": "effectiveReadonly() ? \"true\" : null", "class.ntl-editor-disabled": "effectiveReadonly()" } }, providers: [
501
+ {
502
+ provide: NG_VALUE_ACCESSOR,
503
+ useExisting: forwardRef(() => NotectlEditorComponent),
504
+ multi: true,
505
+ },
506
+ ], viewQueries: [{ propertyName: "hostRef", first: true, predicate: ["host"], descendants: true, isSignal: true }], ngImport: i0, template: '<div #host></div>', isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
329
507
  }
330
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlEditorComponent, decorators: [{
508
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlEditorComponent, decorators: [{
331
509
  type: Component,
332
- args: [{ selector: 'ntl-editor', template: '<div #host></div>', changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block}\n"] }]
333
- }], ctorParameters: () => [], propDecorators: { plugins: [{ type: i0.Input, args: [{ isSignal: true, alias: "plugins", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], readonlyMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonlyMode", required: false }] }], autofocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "autofocus", required: false }] }], maxHistoryDepth: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxHistoryDepth", required: false }] }], theme: [{ type: i0.Input, args: [{ isSignal: true, alias: "theme", required: false }] }], content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }, { type: i0.Output, args: ["contentChange"] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], editorFocus: [{ type: i0.Output, args: ["editorFocus"] }], editorBlur: [{ type: i0.Output, args: ["editorBlur"] }], ready: [{ type: i0.Output, args: ["ready"] }], hostRef: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
510
+ args: [{ selector: 'ntl-editor', standalone: true, template: '<div #host></div>', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
511
+ {
512
+ provide: NG_VALUE_ACCESSOR,
513
+ useExisting: forwardRef(() => NotectlEditorComponent),
514
+ multi: true,
515
+ },
516
+ ], host: {
517
+ '[attr.aria-disabled]': 'effectiveReadonly() ? "true" : null',
518
+ '[class.ntl-editor-disabled]': 'effectiveReadonly()',
519
+ }, styles: [":host{display:block}\n"] }]
520
+ }], ctorParameters: () => [], propDecorators: { plugins: [{ type: i0.Input, args: [{ isSignal: true, alias: "plugins", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], features: [{ type: i0.Input, args: [{ isSignal: true, alias: "features", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], readonlyMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonlyMode", required: false }] }], autofocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "autofocus", required: false }] }], maxHistoryDepth: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxHistoryDepth", required: false }] }], theme: [{ type: i0.Input, args: [{ isSignal: true, alias: "theme", required: false }] }], paperSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "paperSize", required: false }] }], dir: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], styleNonce: [{ type: i0.Input, args: [{ isSignal: true, alias: "styleNonce", required: false }] }], content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }, { type: i0.Output, args: ["contentChange"] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], editorFocus: [{ type: i0.Output, args: ["editorFocus"] }], editorBlur: [{ type: i0.Output, args: ["editorBlur"] }], ready: [{ type: i0.Output, args: ["ready"] }], hostRef: [{ type: i0.ViewChild, args: ['host', { isSignal: true }] }] } });
334
521
 
335
- /** Escapes HTML special characters to prevent XSS when inserting plain text. */
336
- function escapeHtml(text) {
337
- return text
338
- .replace(/&/g, '&amp;')
339
- .replace(/</g, '&lt;')
340
- .replace(/>/g, '&gt;')
341
- .replace(/"/g, '&quot;')
342
- .replace(/'/g, '&#39;');
343
- }
344
522
  /**
345
- * `ControlValueAccessor` directive for Angular forms integration.
346
- *
347
- * Supports Reactive Forms (`formControl`, `formControlName`) and
348
- * template-driven forms (`ngModel`).
523
+ * @deprecated Angular Forms support is built into `NotectlEditorComponent`.
349
524
  *
350
- * The content format is configurable via `provideNotectl({ contentFormat })` or
351
- * the `NOTECTL_CONTENT_FORMAT` injection token:
352
- * - `'json'` (default) — form value is a `Document` object
353
- * - `'html'` — form value is a sanitized HTML string
354
- * - `'text'` — form value is a plain text string
525
+ * This directive remains as a compatibility shim so existing imports do not break,
526
+ * but it no longer participates in value accessor registration.
355
527
  */
356
528
  class NotectlValueAccessorDirective {
357
- editor = inject(NotectlEditorComponent);
358
- destroyRef = inject(DestroyRef);
359
- format = inject(NOTECTL_CONTENT_FORMAT, { optional: true }) ?? 'json';
360
- onChange = () => { };
361
- onTouched = () => { };
362
- pendingValue = null;
363
- suppressEmit = false;
364
- stateChangeSub = this.editor.stateChange.subscribe((_event) => {
365
- if (this.suppressEmit)
366
- return;
367
- void this.readValue().then((value) => {
368
- this.onChange(value);
369
- });
370
- });
371
- blurSub = this.editor.editorBlur.subscribe(() => {
372
- this.onTouched();
373
- });
374
- constructor() {
375
- this.destroyRef.onDestroy(() => {
376
- this.stateChangeSub.unsubscribe();
377
- this.blurSub.unsubscribe();
378
- });
379
- }
380
- writeValue(value) {
381
- if (!value)
382
- return;
383
- try {
384
- this.editor.getState();
385
- void this.writeValueToEditor(value);
386
- return;
387
- }
388
- catch {
389
- // Editor not ready yet — defer
390
- }
391
- this.pendingValue = value;
392
- void this.editor.whenReady().then(async () => {
393
- if (this.pendingValue !== null) {
394
- await this.writeValueToEditor(this.pendingValue);
395
- this.pendingValue = null;
396
- }
397
- });
398
- }
399
- registerOnChange(fn) {
400
- this.onChange = fn;
401
- }
402
- registerOnTouched(fn) {
403
- this.onTouched = fn;
404
- }
405
- setDisabledState(isDisabled) {
406
- this.editor.setReadonly(isDisabled);
407
- }
408
- async writeValueToEditor(value) {
409
- this.suppressEmit = true;
410
- try {
411
- if (this.format === 'json' && typeof value === 'object') {
412
- this.editor.setJSON(value);
413
- }
414
- else if (this.format === 'html' && typeof value === 'string') {
415
- await this.editor.setContentHTML(value);
416
- }
417
- else if (this.format === 'text' && typeof value === 'string') {
418
- await this.editor.setContentHTML(`<p>${escapeHtml(value)}</p>`);
419
- }
420
- else if (typeof value === 'string') {
421
- await this.editor.setContentHTML(value);
422
- }
423
- else {
424
- this.editor.setJSON(value);
425
- }
426
- }
427
- finally {
428
- this.suppressEmit = false;
429
- }
430
- }
431
- async readValue() {
432
- try {
433
- switch (this.format) {
434
- case 'html':
435
- return await this.editor.getContentHTML();
436
- case 'text':
437
- return this.editor.getText();
438
- default:
439
- return this.editor.getJSON();
440
- }
441
- }
442
- catch {
443
- return null;
444
- }
445
- }
446
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlValueAccessorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
447
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.0", type: NotectlValueAccessorDirective, isStandalone: true, selector: "ntl-editor[formControl], ntl-editor[formControlName], ntl-editor[ngModel]", providers: [
448
- {
449
- provide: NG_VALUE_ACCESSOR,
450
- useExisting: forwardRef(() => NotectlValueAccessorDirective),
451
- multi: true,
452
- },
453
- ], ngImport: i0 });
529
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlValueAccessorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
530
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.4", type: NotectlValueAccessorDirective, isStandalone: true, selector: "ntl-editor[formControl], ntl-editor[formControlName], ntl-editor[ngModel]", ngImport: i0 });
454
531
  }
455
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlValueAccessorDirective, decorators: [{
532
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlValueAccessorDirective, decorators: [{
456
533
  type: Directive,
457
534
  args: [{
458
535
  selector: 'ntl-editor[formControl], ntl-editor[formControlName], ntl-editor[ngModel]',
459
- providers: [
460
- {
461
- provide: NG_VALUE_ACCESSOR,
462
- useExisting: forwardRef(() => NotectlValueAccessorDirective),
463
- multi: true,
464
- },
465
- ],
536
+ standalone: true,
466
537
  }]
467
- }], ctorParameters: () => [] });
538
+ }] });
468
539
 
469
540
  /**
470
541
  * Optional injectable service for programmatic editor access via DI.
@@ -498,10 +569,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
498
569
  */
499
570
  class NotectlEditorService {
500
571
  destroyRef = inject(DestroyRef);
501
- editorRef = signal(null, ...(ngDevMode ? [{ debugName: "editorRef" }] : []));
572
+ editorRef = signal(null, ...(ngDevMode ? [{ debugName: "editorRef" }] : /* istanbul ignore next */ []));
502
573
  stateChangeSubject = new Subject();
503
574
  /** Whether an editor is currently registered with this service. */
504
- hasEditor = computed(() => this.editorRef() !== null, ...(ngDevMode ? [{ debugName: "hasEditor" }] : []));
575
+ hasEditor = computed(() => this.editorRef() !== null, ...(ngDevMode ? [{ debugName: "hasEditor" }] : /* istanbul ignore next */ []));
505
576
  /** Observable stream of state change events from the editor. */
506
577
  stateChanges$ = this.stateChangeSubject.asObservable();
507
578
  stateChangeSub = null;
@@ -548,10 +619,10 @@ class NotectlEditorService {
548
619
  dispatch(tr) {
549
620
  this.editorRef()?.dispatch(tr);
550
621
  }
551
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlEditorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
552
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlEditorService });
622
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlEditorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
623
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlEditorService });
553
624
  }
554
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NotectlEditorService, decorators: [{
625
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: NotectlEditorService, decorators: [{
555
626
  type: Injectable
556
627
  }], ctorParameters: () => [] });
557
628