UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

272 lines (254 loc) • 12.5 kB
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; } }