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 +4 -0
- package/dist/foundation/list/formats.js +113 -40
- package/package.json +1 -1
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
|
-
*
|
|
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>.
|
|
489
|
-
//
|
|
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
|
|
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
|
|
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
|
|
640
|
-
const
|
|
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
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* as
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
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("");
|