smartrte-core 1.0.0-beta.3 → 1.0.0-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-beta.4
4
+
5
+ - Fix HTML import (paste and loading a document's initial value) rendering real content as unreadable `[Unsupported: ...]` placeholders when a bare `<span>`/`<b>`/other inline-formatting element sits directly at the document root, or is interleaved between real blocks (a `<blockquote>`, `<div>`-wrapped lines, images) with no wrapping `<p>` — a common shape from legacy, pre-migration editor exports. Also fixes the same gap in `<blockquote>`/table cells/list items for content interleaved between blocks (previously only content entirely before any block content was recovered correctly).
6
+
3
7
  ## 1.0.0-beta.3
4
8
 
5
9
  - Add `href`/`target` (a clickable link), `borderRadius` (corner rounding), and license metadata fields (`licenseDescription`, `licenseSourceUrl`, `licenseType`, `licenseVersion`, `licenseAttribution`) to the image atom schema. `href` is validated the same way as the existing `link` mark; exported HTML wraps a linked image in a real `<a>` for portability outside the editor. Fix a pre-existing renderer gap where clearing an image's `align` back to unset never removed the CSS float/display/margin it had previously applied.
@@ -17,7 +17,7 @@ const isEditorUiNode = (node) => attr(node, "data-smart-ui") !== undefined || at
17
17
  * meaningful block content - most often a <table> - in one or more <div>s
18
18
  * (table-wrapper divs, layout divs) that carry no semantic meaning of
19
19
  * their own. parseBlock has no case for any of these tags; see
20
- * parseBlockList below for how they're unwrapped instead of swallowed.
20
+ * parseMixedBlockContent below for how they're unwrapped instead of swallowed.
21
21
  */
22
22
  const TRANSPARENT_CONTAINER_TAGS = ["div", "section", "article", "figure"];
23
23
  /** Shared by both the inline `image` and block `block_image` parsing branches below - radius/license round-trip identically for both, only href/target's link-mark-fallback behavior deliberately differs between them (see each call site's own comment). */
@@ -485,17 +485,10 @@ const parseBlock = (node) => {
485
485
  if (tag === "blockquote") {
486
486
  // Real-world exports (Sootr among them) put inline content - <span>
487
487
  // wrapper runs, bare text, <br> - directly inside <blockquote> with no
488
- // wrapping <p>. Without this split, elementChildren+parseBlock alone
489
- // sent every such child through the generic "unrecognized tag" fallback
490
- // at the bottom of this function, producing an unknown block node per
491
- // span (rendered as "[Unsupported: span]") instead of parsed text/marks
492
- // - the same directInline pattern td/li already use below.
488
+ // wrapping <p>. See parseMixedBlockContent's own comment for why this
489
+ // must be grouped run-by-run rather than collected into one paragraph.
493
490
  const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
494
- const isBlockLike = (child) => blockTags.includes(child.tagName || "") || TRANSPARENT_CONTAINER_TAGS.includes(child.tagName || "");
495
- const children = parseBlockList(elementChildren(node).filter(isBlockLike));
496
- const directInline = (node.childNodes || []).filter((child) => !child.tagName || !isBlockLike(child)).flatMap((child) => textWithMarks(child));
497
- if (directInline.length)
498
- children.unshift({ type: "paragraph", id: createNodeId(), children: directInline });
491
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
499
492
  return {
500
493
  type: "blockquote", id: generatedId(node, "quote"),
501
494
  ...(Object.keys(parsedBlockAttrs(node)).length ? { attrs: parsedBlockAttrs(node) } : {}),
@@ -584,11 +577,7 @@ const parseBlock = (node) => {
584
577
  if (verticalAlign)
585
578
  cellAttrs.verticalAlign = verticalAlign;
586
579
  const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
587
- const isBlockLike = (child) => blockTags.includes(child.tagName || "") || TRANSPARENT_CONTAINER_TAGS.includes(child.tagName || "");
588
- const children = parseBlockList(elementChildren(node).filter(isBlockLike));
589
- const directInline = (node.childNodes || []).filter((child) => !child.tagName || !isBlockLike(child)).flatMap((child) => textWithMarks(child));
590
- if (directInline.length)
591
- children.unshift({ type: "paragraph", id: createNodeId(), children: directInline });
580
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
592
581
  if (!children.length)
593
582
  children.push({ type: "paragraph", id: createNodeId(), children: [] });
594
583
  return { type: "table_cell", id: generatedId(node, "cell"), attrs: cellAttrs, children };
@@ -636,17 +625,8 @@ const parseBlock = (node) => {
636
625
  attrs.htmlStyle = htmlStyle;
637
626
  if (Number.isInteger(value) && value >= 1)
638
627
  attrs.numberOverride = value;
639
- const children = [];
640
- const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "div", "table", "figure"];
641
- const inlineNodes = (node.childNodes || []).filter((child) => !child.tagName || !blockTags.includes(child.tagName));
642
- const directText = inlineNodes.flatMap((child) => textWithMarks(child));
643
- if (directText.length)
644
- children.push({ type: "paragraph", id: createNodeId(), children: directText });
645
- // blockTags already lists "div"/"figure" as block-worthy, but parseBlock
646
- // itself has no case for either - parseBlockList is what actually
647
- // unwraps them (rather than swallowing their content into one opaque
648
- // `unknown` node) instead of parsing them directly.
649
- children.push(...parseBlockList(elementChildren(node).filter((child) => blockTags.includes(child.tagName || ""))));
628
+ const blockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table"];
629
+ const children = parseMixedBlockContent(node.childNodes || [], blockTags);
650
630
  if (!children.length)
651
631
  children.push({ type: "paragraph", id: createNodeId(), children: [] });
652
632
  return { type: "list_item", id: generatedId(node, "item"), ...(Object.keys(attrs).length ? { attrs } : {}), children };
@@ -659,24 +639,117 @@ const parseBlock = (node) => {
659
639
  return null;
660
640
  };
661
641
  /**
662
- * A thin wrapper around parseBlock for "list of block-level children"
663
- * call sites: a transparent container (TRANSPARENT_CONTAINER_TAGS) is
664
- * recursed into and its own children spliced in flat, rather than parsed
665
- * as one opaque node - a div wrapping N real blocks (a table, N
666
- * paragraphs, or nothing at all) produces exactly those N blocks instead
667
- * of a single `unknown` placeholder that hides all of them.
642
+ * Generic inline-formatting tags recognized by textWithMarks. Used by
643
+ * parseMixedBlockContent to decide when a bare (non-<p>-wrapped) element
644
+ * sitting at block level should be reinterpreted as inline text instead of
645
+ * left as parseBlock's own "unknown" round-trip-preserving placeholder.
646
+ * Deliberately narrow: an arbitrary/custom element (e.g. a host's own safe
647
+ * custom widget surviving the clipboard security boundary, see
648
+ * clipboard/pipeline.test.ts's "preserves a sanitized safe custom element
649
+ * as an unknown node") is NOT in this list, so it keeps going through the
650
+ * exact same "unknown" fallback as before - only tags that are
651
+ * unambiguously plain inline formatting ever get reinterpreted as text.
668
652
  */
669
- const parseBlockList = (nodes) => nodes.flatMap((node) => {
670
- if (TRANSPARENT_CONTAINER_TAGS.includes(node.tagName || ""))
671
- return parseBlockList(elementChildren(node));
672
- const parsed = parseBlock(node);
673
- return parsed ? [parsed] : [];
674
- });
653
+ const GENERIC_INLINE_TAGS = ["span", "strong", "b", "em", "i", "u", "s", "strike", "del", "code", "sup", "sub", "a", "br"];
654
+ /**
655
+ * Parses a mixed sequence of block and inline DOM children in place. A
656
+ * transparent container (TRANSPARENT_CONTAINER_TAGS) is recursed into and
657
+ * its own children spliced in flat, rather than parsed as one opaque node -
658
+ * a div wrapping N real blocks (a table, N paragraphs, or nothing at all)
659
+ * produces exactly those N blocks instead of a single `unknown` placeholder
660
+ * that hides all of them.
661
+ *
662
+ * Real-world exports (Sootr among them - both the document root and inside
663
+ * a <blockquote>/<td>/<li>) routinely put inline content - bare <span>
664
+ * wrapper runs, plain text, stray <br> - directly alongside real block
665
+ * children, with no wrapping <p>. Each *run* of consecutive non-block-like
666
+ * children is grouped into its own synthetic paragraph in place, preserving
667
+ * both the order of the surrounding real blocks and which inline runs were
668
+ * actually adjacent in the source. An earlier version of this function
669
+ * collected every inline child across the whole container into ONE
670
+ * paragraph and prepended it - correct only when inline content never
671
+ * appears between two blocks. Real legacy content interleaves several bare
672
+ * <span> lines between a <blockquote>, images, and paragraphs; merging them
673
+ * all into one paragraph at the front silently reordered content and
674
+ * mashed together lines that were never adjacent, and any other stray
675
+ * generic inline tag (e.g. a lone <b>) still fell through to the generic
676
+ * unrecognized-tag fallback and rendered as "[Unsupported: b]" wherever no
677
+ * caller-specific inline handling existed for it (previously: the document
678
+ * root, which had none at all).
679
+ *
680
+ * "a" is the one GENERIC_INLINE_TAGS entry with a real block-level meaning
681
+ * too - parseBlock's own "<a> wrapping only an <img>" case unwraps to a
682
+ * block image - so it's tried as a block first and only folded into the
683
+ * inline run if that doesn't apply.
684
+ */
685
+ const parseMixedBlockContent = (nodes, blockTags) => {
686
+ const result = [];
687
+ let inlineRun = [];
688
+ const flushInlineRun = () => {
689
+ if (!inlineRun.length)
690
+ return;
691
+ const children = inlineRun.flatMap((node) => textWithMarks(node));
692
+ inlineRun = [];
693
+ // A whitespace-only text node (a newline/indentation between block-level
694
+ // tags in pretty-printed source HTML - e.g. Word/Excel's real clipboard
695
+ // HTML) is insignificant, exactly like a browser's own whitespace
696
+ // collapsing between block elements - it must not produce its own
697
+ // spurious empty-looking paragraph. A run with any real text or any
698
+ // non-text child (an atom, a <br>) is kept in full, including any
699
+ // whitespace mixed into it (e.g. a genuine word-separating space).
700
+ const hasContent = children.some((child) => !isTextNode(child) || child.text.trim() !== "");
701
+ if (hasContent)
702
+ result.push({ type: "paragraph", id: createNodeId(), children });
703
+ };
704
+ const pushBlock = (node) => {
705
+ flushInlineRun();
706
+ const parsed = parseBlock(node);
707
+ if (parsed)
708
+ result.push(parsed);
709
+ };
710
+ nodes.forEach((node) => {
711
+ // Matches textWithMarks's own first check - a UI-only element (e.g. a
712
+ // checklist item's check-control <button>) has no tag this function
713
+ // would otherwise recognize as block-like, and previously reached
714
+ // parseBlock's generic "unknown" fallback instead of being silently
715
+ // dropped like every other reparse path already does for these.
716
+ if (isEditorUiNode(node))
717
+ return;
718
+ const tag = node.tagName;
719
+ if (!tag) {
720
+ inlineRun.push(node);
721
+ return;
722
+ }
723
+ if (TRANSPARENT_CONTAINER_TAGS.includes(tag)) {
724
+ flushInlineRun();
725
+ result.push(...parseMixedBlockContent(node.childNodes || [], blockTags));
726
+ return;
727
+ }
728
+ if (blockTags.includes(tag))
729
+ return pushBlock(node);
730
+ if (GENERIC_INLINE_TAGS.includes(tag)) {
731
+ if (tag === "a") {
732
+ const parsed = parseBlock(node);
733
+ if (parsed && parsed.type !== "unknown") {
734
+ flushInlineRun();
735
+ result.push(parsed);
736
+ return;
737
+ }
738
+ }
739
+ inlineRun.push(node);
740
+ return;
741
+ }
742
+ pushBlock(node);
743
+ });
744
+ flushInlineRun();
745
+ return result;
746
+ };
675
747
  export const parseCanonicalListHtml = (html) => {
676
748
  const fragment = parseFragment(html);
677
749
  const wrapper = elementChildren(fragment).find((node) => attr(node, "data-smart-document") === "true");
678
750
  const source = wrapper || fragment;
679
- const children = parseBlockList(elementChildren(source));
751
+ const rootBlockTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "table", "hr", "img", "video", "audio"];
752
+ const children = parseMixedBlockContent(source.childNodes || [], rootBlockTags);
680
753
  return { type: "doc", id: attr(source, "data-smart-id") || createNodeId(), children: children.length ? children : [{ type: "paragraph", id: createNodeId(), children: [] }] };
681
754
  };
682
755
  const plainText = (node) => (node.children || []).map((child) => isTextNode(child) ? child.text : child.type === "hard_break" ? "\n" : "").join("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartrte-core",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Framework-agnostic document model and editing primitives for Smart RTE",
5
5
  "repository": {
6
6
  "type": "git",