@@ -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' ] && / < i m g \b / i. test ( trimmedHtml ) ) ||
356+ ( schema . nodes [ 'horizontalRule' ] && / < h r \b / i. test ( trimmedHtml ) ) ||
357+ ( schema . nodes [ 'table' ] && / < t a b l e \b / i. test ( trimmedHtml ) ) ||
358+ ( schema . nodes [ 'mention' ] &&
359+ / < s p a n \b [ ^ > ] * \b d a t a - q a l m a - m e n t i o n \b / i. test ( trimmedHtml ) )
360+ ) {
361+ return false ;
362+ }
363+
364+ return (
365+ trimmedHtml
366+ . replace ( / < b r \b [ ^ > ] * > / gi, '' )
367+ . replace ( / < [ ^ > ] + > / g, '' )
368+ . replace ( / & n b s p ; | & # 1 6 0 ; | & # x a 0 ; / gi, ' ' )
369+ . replace ( / & # 8 2 0 3 ; | & # x 2 0 0 b ; | & Z e r o W i d t h S p a c e ; / 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