Skip to content

Commit b22100b

Browse files
committed
fix(editor): preserve pre-mount empty checks
1 parent e748edf commit b22100b

3 files changed

Lines changed: 107 additions & 9 deletions

File tree

libs/editor/forms/src/control-value-accessor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class QalmaControlValueAccessor
9696
/**
9797
* Current control value for the editor. Reads `html()` unconditionally so the
9898
* `effect` tracks it, then collapses an empty document to `''` via the
99-
* DOM-free `isEmpty()` query (so `Validators.required` behaves).
99+
* editor's `isEmpty()` query (so `Validators.required` behaves).
100100
*/
101101
private currentValue(): string {
102102
const editor = this.editorHost.editor();

libs/editor/src/lib/editor/qalma-editor-controller.spec.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { computed } from '@angular/core';
2-
3-
import { HardBreakPlugin, HorizontalRulePlugin, ImagePlugin } from '../../index';
4-
import { createQalmaEditor } from '../../index';
2+
import { vi } from 'vitest';
3+
4+
import {
5+
BlockquotePlugin,
6+
HardBreakPlugin,
7+
HorizontalRulePlugin,
8+
ImagePlugin,
9+
ListsPlugin,
10+
createQalmaEditor,
11+
} from '../../index';
512
import { mountEditor } from '../../../testing/editor-test-utils';
613

714
describe('QalmaEditorController.isEmpty', () => {
@@ -101,4 +108,52 @@ describe('QalmaEditorController.isEmpty', () => {
101108
expect(createQalmaEditor({ content: '<p></p>' }).isEmpty()).toBe(true);
102109
expect(createQalmaEditor({ content: '<p>Draft</p>' }).isEmpty()).toBe(false);
103110
});
111+
112+
it('uses the document model for serialized empty content before mount', () => {
113+
expect(createQalmaEditor({ content: '<p>&nbsp;</p>' }).isEmpty()).toBe(true);
114+
expect(
115+
createQalmaEditor({
116+
content: '<p><br></p>',
117+
plugins: [HardBreakPlugin],
118+
}).isEmpty(),
119+
).toBe(true);
120+
expect(
121+
createQalmaEditor({
122+
content: '<blockquote><p></p></blockquote>',
123+
plugins: [BlockquotePlugin],
124+
}).isEmpty(),
125+
).toBe(true);
126+
expect(
127+
createQalmaEditor({
128+
content: '<ul><li><p></p></li></ul>',
129+
plugins: [ListsPlugin],
130+
}).isEmpty(),
131+
).toBe(true);
132+
});
133+
134+
it('keeps the pre-mount empty check safe without a document global', () => {
135+
const originalDocument = globalThis.document;
136+
137+
vi.stubGlobal('document', undefined);
138+
139+
try {
140+
expect(createQalmaEditor({ content: '<p>&nbsp;</p>' }).isEmpty()).toBe(
141+
true,
142+
);
143+
expect(createQalmaEditor({ content: '<p><br></p>' }).isEmpty()).toBe(
144+
true,
145+
);
146+
expect(createQalmaEditor({ content: '<p>Draft</p>' }).isEmpty()).toBe(
147+
false,
148+
);
149+
expect(
150+
createQalmaEditor({
151+
content: '<hr>',
152+
plugins: [HorizontalRulePlugin],
153+
}).isEmpty(),
154+
).toBe(false);
155+
} finally {
156+
vi.stubGlobal('document', originalDocument);
157+
}
158+
});
104159
});

libs/editor/src/lib/editor/qalma-editor-controller.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,9 @@ export class QalmaEditorController {
229229
* Whether the document has no user-visible content: no text and no media
230230
* (images, mentions, tables, horizontal rules). Empty paragraphs, empty
231231
* structural containers (blockquotes, lists) and hard breaks all count as
232-
* empty. Computed from the document model, so it never touches the DOM and is
233-
* safe to call during server-side rendering — unlike re-parsing `html()`.
232+
* empty. Computed from the document model when one exists; before mount,
233+
* serialized HTML is parsed through the editor schema when a DOM parser is
234+
* available, with a schema-aware string fallback for server-side rendering.
234235
* Tracks editor state like the other read models, so it stays live inside
235236
* `computed`/`effect`. Form adapters use it to normalize an empty editor to an
236237
* empty control value.
@@ -244,9 +245,7 @@ export class QalmaEditorController {
244245
return isEmptyDocument(doc);
245246
}
246247

247-
const html = this.html().trim();
248-
249-
return html === '' || html === EMPTY_DOCUMENT_HTML;
248+
return isEmptyHtmlDocument(this.html(), this.schema);
250249
}
251250

252251
setEditable(editable: boolean): void {
@@ -330,6 +329,50 @@ function isEmptyDocument(doc: ProseMirrorNode): boolean {
330329
return !hasContent;
331330
}
332331

332+
function isEmptyHtmlDocument(html: string, schema: Schema): boolean {
333+
if (canParseHtmlDocument()) {
334+
return isEmptyDocument(parseHtmlDocument(html, schema));
335+
}
336+
337+
return isEmptySerializedHtml(html, schema);
338+
}
339+
340+
function canParseHtmlDocument(): boolean {
341+
return (
342+
typeof document !== 'undefined' &&
343+
typeof document.createElement === 'function'
344+
);
345+
}
346+
347+
function isEmptySerializedHtml(html: string, schema: Schema): boolean {
348+
const trimmedHtml = html.trim();
349+
350+
if (trimmedHtml === '') {
351+
return true;
352+
}
353+
354+
if (
355+
(schema.nodes['image'] && /<img\b/i.test(trimmedHtml)) ||
356+
(schema.nodes['horizontalRule'] && /<hr\b/i.test(trimmedHtml)) ||
357+
(schema.nodes['table'] && /<table\b/i.test(trimmedHtml)) ||
358+
(schema.nodes['mention'] &&
359+
/<span\b[^>]*\bdata-qalma-mention\b/i.test(trimmedHtml))
360+
) {
361+
return false;
362+
}
363+
364+
return (
365+
trimmedHtml
366+
.replace(/<br\b[^>]*>/gi, '')
367+
.replace(/<[^>]+>/g, '')
368+
.replace(/&nbsp;|&#160;|&#xa0;/gi, ' ')
369+
.replace(/&#8203;|&#x200b;|&ZeroWidthSpace;/gi, '')
370+
.replace(/\u00a0/g, ' ')
371+
.replace(/\u200b/g, '')
372+
.trim().length === 0
373+
);
374+
}
375+
333376
/**
334377
* A node that counts as content even when the document carries no text: media
335378
* atoms (images, mentions), tables, and block-level leaves (horizontal rules,

0 commit comments

Comments
 (0)