@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
272 lines (254 loc) • 12.5 kB
JavaScript
import { expandedState } from '@atlaskit/editor-common/expand';
import { DOMSerializer } from '@atlaskit/editor-prosemirror/model';
import { contains } from '@atlaskit/editor-prosemirror/utils';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { createEditorProxy } from './createEditorProxy';
import { getAtomicInlineChangedAttrs } from './decorations/createInlineChangedDecoration';
import { isInlineAttrChangeNodeName } from './decorations/utils/getAttrChangeRanges';
import { wrapInMarkViews } from './markViews';
/**
* Utilities for working with ProseMirror node views and DOM serialization within the
* Show Diff editor plugin.
*
* This module centralizes:
* - Access to the editor's `nodeViews` registry (when available on `EditorView`)
* - Safe attempts to instantiate a node view for a given node, with a blocklist to
* avoid node types that are known to be problematic in this context (e.g. tables)
* - Schema-driven serialization of nodes and fragments to DOM via `DOMSerializer`
*
* The Show Diff decorations leverage this to either render nodes using their
* corresponding node view implementation, or fall back to DOM serialization.
*/
/**
* Narrowed `EditorView` that exposes the internal `nodeViews` registry.
* Many editor instances provide this, but it's not part of the base type.
*/
/**
* Type guard to detect whether an `EditorView` exposes a `nodeViews` map.
*/
export function isEditorViewWithNodeViews(view) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return view.nodeViews !== undefined;
}
/** Expand-family nodes whose expanded state lives in a node-keyed WeakMap. */
const EXPAND_TYPES = new Set(['expand', 'nestedExpand']);
/** Merges an inline style string onto an element's existing `style` attribute. */
const appendInlineStyle = (element, style) => {
var _element$getAttribute;
const currentStyle = (_element$getAttribute = element.getAttribute('style')) !== null && _element$getAttribute !== void 0 ? _element$getAttribute : '';
const separator = currentStyle && style && !currentStyle.trimEnd().endsWith(';') ? '; ' : '';
element.setAttribute('style', `${currentStyle}${separator}${style}`);
};
/** Tables are the only node views that read and write positions, so only they need a preview. */
const containsTable = node => {
const {
table
} = node.type.schema.nodes;
// `contains` only checks descendants, so the node itself is checked separately.
return !!table && (node.type === table || contains(node, table));
};
/**
* Encapsulates DOM serialization and node view access/creation.
*
* Responsible for:
* - Creating a `DOMSerializer` from the provided schema
* - Reading `nodeViews` from an `EditorView` (if present) or using an explicit mapping
* - Preventing node view creation for blocklisted node types
*/
export class NodeViewSerializer {
constructor(params) {
var _params$blocklist;
if (params !== null && params !== void 0 && params.editorView) {
this.init({
editorView: params.editorView
});
}
this.nodeViewBlocklist = new Set((_params$blocklist = params.blocklist) !== null && _params$blocklist !== void 0 ? _params$blocklist : ['paragraph']);
}
/**
* Initializes or reinitializes the NodeViewSerializer with a new EditorView.
* This allows the same serializer instance to be reused across different editor states.
*/
init(params) {
var _params$editorView, _ref, _this$editorView;
this.serializer = DOMSerializer.fromSchema(params.editorView.state.schema);
if (isEditorViewWithNodeViews(params.editorView)) {
this.editorView = params.editorView;
}
const nodeViews =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((_params$editorView = params.editorView) === null || _params$editorView === void 0 ? void 0 : _params$editorView.nodeViews) || {};
this.nodeViews = (_ref = nodeViews !== null && nodeViews !== void 0 ? nodeViews : (_this$editorView = this.editorView) === null || _this$editorView === void 0 ? void 0 : _this$editorView.nodeViews) !== null && _ref !== void 0 ? _ref : {};
}
/**
* Appends serialized child nodes to the given contentDOM element.
*/
appendChildNodes(children, contentDOM, basePos = 0, editorProxy, isInserted, colorScheme) {
// A node's first child sits one position inside it, and each subsequent
// child is offset by the previous child's `nodeSize`. Tracking this lets nested node
// views resolve to their real depth instead of every one of them seeing depth 0.
let childPos = basePos + 1;
children.forEach(child => {
const childNode = this.tryCreateNodeViewInner(child, childPos, editorProxy, isInserted, colorScheme) || this.serializeNode(child);
if (childNode) {
// Give inserted atomic children the same box-shadow
// highlight an in-place attribute change already gets (`getAtomicInlineChangedAttrs`).
if (isInserted && isExperimentEnabled('platform_editor_show_diff_deleted_nodeview_content') && isInlineAttrChangeNodeName(child.type.name) && childNode instanceof HTMLElement) {
var _childNode$getAttribu;
const {
className,
styleSuffix
} = getAtomicInlineChangedAttrs(child.type.name, colorScheme);
const existingClassName = (_childNode$getAttribu = childNode.getAttribute('class')) !== null && _childNode$getAttribu !== void 0 ? _childNode$getAttribu : '';
childNode.setAttribute('class', existingClassName ? `${existingClassName} ${className}` : className);
if (styleSuffix) {
appendInlineStyle(childNode, styleSuffix);
}
}
contentDOM === null || contentDOM === void 0 ? void 0 : contentDOM.append(childNode);
}
childPos += child.nodeSize;
});
}
/**
* Attempts to create a node view for the given node.
*
* Returns `null` when there is no `EditorView`, no constructor for the node type,
* or the node type is blocklisted. Otherwise returns the constructed node view instance.
*
* `$sourcePos` is where the node sits in the doc it came from, so nested tables keep their depth.
*/
tryCreateNodeView(targetNode, basePos = 0, $sourcePos, isInserted, colorScheme) {
var _preview$rootPos;
if (!this.editorView) {
return null;
}
// The preview becomes the document every nested node view resolves its position against,
// so positions stay internally consistent.
const preview = fg('platform_editor_ai_show_diff_patch_2') && containsTable(targetNode) ? createEditorProxy(this.editorView, targetNode, $sourcePos) : null;
return this.tryCreateNodeViewInner(targetNode, (_preview$rootPos = preview === null || preview === void 0 ? void 0 : preview.rootPos) !== null && _preview$rootPos !== void 0 ? _preview$rootPos : basePos, preview === null || preview === void 0 ? void 0 : preview.editorProxy, isInserted, colorScheme);
}
tryCreateNodeViewInner(targetNode, basePos = 0, editorProxy, isInserted, colorScheme) {
var _this$nodeViews;
if (!this.editorView) {
return null;
}
const constructor = (_this$nodeViews = this.nodeViews) === null || _this$nodeViews === void 0 ? void 0 : _this$nodeViews[targetNode.type.name];
const isBlocklisted = this.nodeViewBlocklist.has(targetNode.type.name);
// Do not bail out a blocklisted container when it holds an atomic inline node
// (e.g. date, status) whose schema toDOM is lossy. Text keeps bailing so the caller
// renders it inline
const hasAtomicInlineChild = node => {
let found = false;
node.forEach(child => {
if (child.isLeaf && !child.isText) {
found = true;
}
});
return found;
};
const serializeAtomicContainer = isExperimentEnabled('platform_editor_show_diff_deleted_nodeview_content') && !targetNode.isInline && hasAtomicInlineChild(targetNode);
if (isBlocklisted && !serializeAtomicContainer) {
return null;
}
try {
// No constructor, or (gated) the node's own view is blocklisted: render the toDOM
// shell and recurse via appendChildNodes so nested atomic inline nodes render via
// their node views instead of their lossy schema toDOM.
if (!constructor || isExperimentEnabled('platform_editor_show_diff_deleted_nodeview_content') && isBlocklisted) {
var _targetNode$type$spec, _targetNode$type$spec2;
if (targetNode.isInline) {
return null;
}
const toDOMResult = (_targetNode$type$spec = (_targetNode$type$spec2 = targetNode.type.spec).toDOM) === null || _targetNode$type$spec === void 0 ? void 0 : _targetNode$type$spec.call(_targetNode$type$spec2, targetNode);
if (!toDOMResult) {
return null;
}
const {
dom,
contentDOM
} = DOMSerializer.renderSpec(document, toDOMResult);
if (dom instanceof HTMLElement) {
// Legacy shortcut: a single-child paragraph serializes its inline content
// directly (no <p> wrapper). Applies only to a non-blocklisted paragraph with
// no node view
if (!isExperimentEnabled('platform_editor_show_diff_deleted_nodeview_content') && targetNode.type.name === 'paragraph' && targetNode.children.length === 1) {
return this.serializeFragment(targetNode.content);
}
this.appendChildNodes(targetNode.children, contentDOM, basePos, editorProxy, isInserted, colorScheme);
}
return this.withMarkViews(targetNode, dom, editorProxy);
}
// The expand node view reads `expandedState.get(node) ?? false`, never `attrs.__expanded`.
// Open the preview so its tables get real layout; an existing entry is the live expand's
// own state, so leave it alone.
if (EXPAND_TYPES.has(targetNode.type.name) && !expandedState.has(targetNode) && fg('platform_editor_ai_show_diff_patch_2')) {
expandedState.set(targetNode, true);
}
// `isTableNested` is `doc.resolve(pos).depth > 0`, so the old hardcoded `0` made nested
// tables size as top-level. Positions are resolved against the preview document, so
// they are only meaningful with an editor proxy.
const view = editorProxy !== null && editorProxy !== void 0 ? editorProxy : this.editorView;
const docSize = view.state.doc.content.size;
const resolvedPos = editorProxy ? Math.max(0, Math.min(basePos, docSize)) : 0;
const {
dom,
contentDOM
} = constructor(targetNode, view, () => resolvedPos, [], {});
// Iteratively populate children
this.appendChildNodes(targetNode.children, contentDOM, basePos, editorProxy, isInserted, colorScheme);
return this.withMarkViews(targetNode, dom, editorProxy);
} catch {
return null;
}
}
/** Wraps rendered DOM in its marks, rendered against the editor proxy when there is one. */
withMarkViews(targetNode, nodeDom, editorProxy) {
return wrapInMarkViews(targetNode, nodeDom, {
nodeViews: this.nodeViews,
view: editorProxy !== null && editorProxy !== void 0 ? editorProxy : this.editorView
});
}
/**
* Serializes a node to a DOM `Node` using the schema's `DOMSerializer`.
*/
serializeNode(node) {
if (!this.serializer) {
throw new Error('NodeViewSerializer must be initialized with init() before use');
}
try {
return this.serializer.serializeNode(node);
} catch {
return null;
}
}
/**
* Serializes a fragment to a `DocumentFragment` using the schema's `DOMSerializer`.
*/
serializeFragment(fragment) {
if (!this.serializer) {
throw new Error('NodeViewSerializer must be initialized with init() before use');
}
try {
return this.serializer.serializeFragment(fragment);
} catch {
return null;
}
}
/**
* Returns a copy of the current node view blocklist.
*/
getNodeViewBlocklist() {
return new Set(this.nodeViewBlocklist);
}
/**
* Returns a filtered copy of the node view blocklist, excluding specified node types.
* @param excludeTypes - Array of node type names to exclude from the blocklist
*/
getFilteredNodeViewBlocklist(excludeTypes) {
const filtered = new Set(this.nodeViewBlocklist);
excludeTypes.forEach(type => filtered.delete(type));
return filtered;
}
}