Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
GridBlock,
ImageNode,
LoaderNode,
UnsupportedBlockMark,
UnsupportedBlockNode,
VideoNode
} from '../../nodes';
Expand All @@ -87,6 +88,7 @@ import {
removeInvalidNodes,
restoreUnknownBlockNodes,
RestoreDefaultDOMAttrs,
preserveUnknownBlockMarks,
preserveUnknownBlockNodes,
SetDocAttrStep
} from '../../shared';
Expand Down Expand Up @@ -115,6 +117,7 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co
readonly #injector = inject(Injector);
/** Schema node names captured as soon as the TipTap editor instance exists. */
readonly #knownEditorNodeNames = new Set<string>();
readonly #knownEditorMarkNames = new Set<string>();
/** Buffers incoming form content until the editor create lifecycle can safely consume it. */
#pendingValue: Content | null = null;
/** Field-level allowed blocks, with paragraph forced in as the legacy default. */
Expand Down Expand Up @@ -269,6 +272,10 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co
Object.keys(this.editor.schema.nodes).forEach((nodeName) =>
this.#knownEditorNodeNames.add(nodeName)
);
this.#knownEditorMarkNames.clear();
Object.keys(this.editor.schema.marks).forEach((markName) =>
this.#knownEditorMarkNames.add(markName)
);

this.dotMarketingConfigService.setProperty(
EDITOR_MARKETING_KEYS.SHOW_VIDEO_THUMBNAIL,
Expand Down Expand Up @@ -732,6 +739,7 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co
*/
private getEditorMarks() {
return [
UnsupportedBlockMark,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph', 'listItem', 'dotImage'] }),
Highlight.configure({ HTMLAttributes: { style: 'background: #accef7;' } }),
Expand Down Expand Up @@ -770,11 +778,20 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co
return;
}

// Nodes first, then marks: an unknown node is swallowed whole into the placeholder's
// `originalNode` payload, which must stay exactly as stored, so the mark pass only
// ever walks what is left of the real tree.
const preservedContent = Array.isArray(content)
? preserveUnknownBlockNodes(content, this.#knownEditorNodeNames)
? preserveUnknownBlockMarks(
preserveUnknownBlockNodes(content, this.#knownEditorNodeNames),
this.#knownEditorMarkNames
)
: {
...content,
content: preserveUnknownBlockNodes(content.content, this.#knownEditorNodeNames)
content: preserveUnknownBlockMarks(
preserveUnknownBlockNodes(content.content, this.#knownEditorNodeNames),
this.#knownEditorMarkNames
)
};

this.content =
Expand Down
1 change: 1 addition & 0 deletions core-web/libs/block-editor/src/lib/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './ai-content/ai-content.node';
export * from './loader/loader.node';
export * from './grid-block';
export * from './unsupported-block/unsupported-block.node';
export * from './unsupported-block/unsupported-block.mark';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createUnsupportedBlockMark } from '@dotcms/dotcms-models';

/**
* Mark-side half of `UnsupportedBlockNode`: preserves a mark this schema does not declare
* so it round-trips instead of aborting `Node.fromJSON` for the whole document (#37175).
*/
export const UnsupportedBlockMark = createUnsupportedBlockMark();
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
export {
createUnknownBlockMarkAttrs,
createUnknownBlockNodeAttrs,
parseUnknownBlockOriginalMark,
parseUnknownBlockOriginalNode,
preserveUnknownBlockMarks,
preserveUnknownBlockNodes,
renderUnknownBlockOriginalMark,
renderUnknownBlockOriginalNode,
restoreUnknownBlockNodes,
UNKNOWN_BLOCK_MARK_NAME,
UNKNOWN_BLOCK_NODE_NAME
} from '@dotcms/dotcms-models';
219 changes: 218 additions & 1 deletion core-web/libs/dotcms-models/src/lib/unknown-block.util.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { JSONContent, Node } from '@tiptap/core';
import { JSONContent, Mark, Node } from '@tiptap/core';

export const UNKNOWN_BLOCK_NODE_NAME = 'dotUnsupportedBlock';
export const UNKNOWN_BLOCK_MARK_NAME = 'dotUnsupportedMark';

type JSONLike = JSONContent | JSONContent[];
type JSONLikeOrUndefined = JSONLike | undefined;

/** The JSON shape TipTap uses for an entry of `JSONContent.marks`. */
type JSONMark = NonNullable<JSONContent['marks']>[number];

export interface UnknownBlockNodeAttrs {
originalType: string | null;
originalNode: JSONContent | null;
originalNodeRaw: string | null;
}

export interface UnknownBlockMarkAttrs {
originalType: string | null;
originalMark: JSONMark | null;
originalMarkRaw: string | null;
}

function isJsonContent(value: unknown): value is JSONContent {
const type = (value as JSONContent | null | undefined)?.type;

Expand All @@ -23,6 +33,18 @@ function isJsonContent(value: unknown): value is JSONContent {
);
}

/**
* Mark-side counterpart of {@link isJsonContent}. A mark JSON shares the same
* "object carrying a non-empty string `type`" shape, so this delegates — but it is a
* separate name on purpose. `isJsonContent` is free to tighten around node-specific rules
* (requiring `content`, or checking the name against the schema's nodes), and mark
* restoration must not start failing when it does. That failure would be silent: the symptom
* is a `dotUnsupportedMark` written to storage, not an exception.
*/
function isJsonMark(value: unknown): value is JSONMark {
return isJsonContent(value);
}

/**
* Builds the placeholder attrs stored on `dotUnsupportedBlock`.
*
Expand All @@ -42,6 +64,21 @@ export function createUnknownBlockNodeAttrs(
};
}

/**
* Builds the placeholder attrs stored on `dotUnsupportedMark` — the mark-side
* counterpart of {@link createUnknownBlockNodeAttrs}.
*/
export function createUnknownBlockMarkAttrs(
mark: JSONMark,
markType: string | null
): UnknownBlockMarkAttrs {
return {
originalType: markType,
originalMark: mark,
originalMarkRaw: null
};
}

/**
* Parses the serialized `data-original-node` HTML attribute back into JSON when
* possible, and preserves the raw string separately when parsing fails so the
Expand Down Expand Up @@ -72,6 +109,32 @@ export function parseUnknownBlockOriginalNode(
}
}

/** Mark-side counterpart of {@link parseUnknownBlockOriginalNode}. */
export function parseUnknownBlockOriginalMark(
value: string | null
): Pick<UnknownBlockMarkAttrs, 'originalMark' | 'originalMarkRaw'> {
if (!value) {
return {
originalMark: null,
originalMarkRaw: null
};
}

try {
return {
originalMark: JSON.parse(value),
originalMarkRaw: null
};
} catch (error) {
console.warn('[unsupported-mark] failed to parse originalMark', error);

return {
originalMark: null,
originalMarkRaw: value
};
}
}

/**
* Re-renders the preserved original node payload back onto the placeholder DOM
* node so unsupported blocks keep their recoverable serialized representation.
Expand All @@ -88,6 +151,19 @@ export function renderUnknownBlockOriginalNode(
: {};
}

/** Mark-side counterpart of {@link renderUnknownBlockOriginalNode}. */
export function renderUnknownBlockOriginalMark(
attributes: Partial<UnknownBlockMarkAttrs>
): Record<string, string> {
if (isJsonMark(attributes.originalMark)) {
return { 'data-original-mark': JSON.stringify(attributes.originalMark) };
}

return typeof attributes.originalMarkRaw === 'string' && attributes.originalMarkRaw.length > 0
? { 'data-original-mark': attributes.originalMarkRaw }
: {};
}

export function createUnsupportedBlockNode() {
return Node.create({
name: UNKNOWN_BLOCK_NODE_NAME,
Expand Down Expand Up @@ -144,6 +220,70 @@ export function createUnsupportedBlockNode() {
});
}

/**
* Placeholder for a mark the current schema does not know, and the reason unknown marks
* no longer blank the whole field (#37175).
*
* Unlike an unknown *node*, an unknown *mark* has nothing to show: the text it decorates
* is ordinary text that must keep rendering and stay editable. So this mark is visually
* neutral — it only carries the original mark payload so it round-trips back on save
* instead of being dropped.
*
* `inclusive: false` keeps the mark from swallowing text typed at its boundary, so an
* author extending that sentence does not silently widen a mark this editor cannot render.
*
* KNOWN LIMITATION — "Clear formatting" discards the preserved payload. That action runs
* `unsetAllMarks().clearNodes()` (`toolbar.component.ts`; the legacy bubble menu runs
* `unsetAllMarks()`), which strips this placeholder along with every real mark — and
* `clearNodes()` does the same to `dotUnsupportedBlock`. Since neither placeholder renders
* any visible formatting, the author has no way to know something was discarded, and there
* is no way back. It is still strictly better than the pre-fix behaviour, where the document
* did not load at all, and unlike that, it is user-initiated. Excluding BOTH placeholders
* from the clear-formatting command is worth doing, but it belongs to that command in each
* editor rather than here — the node half is affected the same way and predates this mark.
*/
export function createUnsupportedBlockMark() {
return Mark.create({
name: UNKNOWN_BLOCK_MARK_NAME,
inclusive: false,
Comment thread
adrianjm-dotCMS marked this conversation as resolved.

addAttributes() {
return {
originalType: {
default: null,
parseHTML: (element) => element.getAttribute('data-original-type'),
renderHTML: (attributes) =>
attributes['originalType']
? { 'data-original-type': attributes['originalType'] }
: {}
},
originalMark: {
default: null,
parseHTML: (element) =>
parseUnknownBlockOriginalMark(element.getAttribute('data-original-mark'))
.originalMark,
renderHTML: (attributes) => renderUnknownBlockOriginalMark(attributes)
},
originalMarkRaw: {
default: null,
parseHTML: (element) =>
parseUnknownBlockOriginalMark(element.getAttribute('data-original-mark'))
.originalMarkRaw,
renderHTML: () => ({})
}
};
},

parseHTML() {
return [{ tag: `span[data-mark-type="${UNKNOWN_BLOCK_MARK_NAME}"]` }];
},

renderHTML({ HTMLAttributes }) {
return ['span', { ...HTMLAttributes, 'data-mark-type': UNKNOWN_BLOCK_MARK_NAME }, 0];
}
});
}

/**
* Replaces any node whose type is unknown to the current schema with the shared
* unsupported-block placeholder while recursively preserving unknown descendants
Expand Down Expand Up @@ -180,6 +320,77 @@ export function preserveUnknownBlockNodes<T extends JSONLikeOrUndefined>(
return content.map((node) => replaceUnknownNode(node, knownNodeNames)) as T;
}

/** Swaps a single mark for the placeholder when the schema does not declare it. */
function replaceUnknownMark(mark: JSONMark, knownMarkNames: Set<string>): JSONMark {
const markType = typeof mark?.type === 'string' ? mark.type : null;

if (markType && knownMarkNames.has(markType)) {
return mark;
}

return {
type: UNKNOWN_BLOCK_MARK_NAME,
attrs: createUnknownBlockMarkAttrs(mark, markType)
};
}

function replaceUnknownMarksInNode(node: JSONContent, knownMarkNames: Set<string>): JSONContent {
// A `dotUnsupportedBlock` placeholder already holds its whole original payload —
// marks included — in `attrs.originalNode`. That payload is inert data rather than
// part of the document tree, so it has to be left byte-for-byte as stored.
if (node.type === UNKNOWN_BLOCK_NODE_NAME) {
return node;
}

return {
...node,
marks: Array.isArray(node.marks)
? node.marks.map((mark) => replaceUnknownMark(mark, knownMarkNames))
: node.marks,
content: preserveUnknownBlockMarks(node.content, knownMarkNames)
};
}

/**
* Replaces every mark the schema does not declare with the `dotUnsupportedMark`
* placeholder, so `Node.fromJSON` never meets a mark type it cannot resolve.
*
* This is the mark-side half of {@link preserveUnknownBlockNodes} and must run *after*
* it: nodes first, so content already swallowed into a `dotUnsupportedBlock` payload is
* not rewritten, then marks over what remains of the real tree.
*
* Without this an unknown mark threw `RangeError: There is no mark type X in this schema`
* mid-recursion, aborting `Node.fromJSON` for the ENTIRE document. TipTap catches that,
* warns, and boots an empty document — so the field looked emptied while the stored JSON
* was intact, and the next save made the loss real (#37175).
*/
export function preserveUnknownBlockMarks<T extends JSONLikeOrUndefined>(
content: T,
knownMarkNames: Set<string>
): T {
if (!content) {
return content;
}

if (!Array.isArray(content)) {
return replaceUnknownMarksInNode(content, knownMarkNames) as T;
}

return content.map((node) => replaceUnknownMarksInNode(node, knownMarkNames)) as T;
}

/**
* Restores a placeholder mark back to the mark it stood in for, keeping the placeholder
* when the payload is no longer valid so the recoverable raw string still round-trips.
*/
function restoreUnknownMark(mark: JSONMark): JSONMark {
if (mark?.type === UNKNOWN_BLOCK_MARK_NAME && isJsonMark(mark.attrs?.['originalMark'])) {
return mark.attrs['originalMark'] as JSONMark;
}

return mark;
}

/**
* Restores a placeholder node back to its original JSON only when the preserved
* payload is still a valid TipTap node; otherwise the placeholder is kept so the
Expand All @@ -192,10 +403,16 @@ function restoreUnknownBlockNode(node: JSONContent): JSONContent {

return {
...node,
marks: Array.isArray(node.marks) ? node.marks.map(restoreUnknownMark) : node.marks,
content: restoreUnknownBlockNodes(node.content)
};
}

/**
* Inverse of {@link preserveUnknownBlockNodes} + {@link preserveUnknownBlockMarks}: turns
* both placeholders back into the payloads they preserved, so what gets saved is the
* content that was loaded plus the author's edits — never the placeholders themselves.
*/
export function restoreUnknownBlockNodes<T extends JSONLikeOrUndefined>(content: T): T {
if (!content) {
return content;
Expand Down
Loading
Loading