607 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			607 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
/**
 | 
						|
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 | 
						|
 *
 | 
						|
 * This source code is licensed under the MIT license found in the
 | 
						|
 * LICENSE file in the root directory of this source tree.
 | 
						|
 *
 | 
						|
 */
 | 
						|
 | 
						|
import type {
 | 
						|
  BaseSelection,
 | 
						|
  DOMConversionMap,
 | 
						|
  DOMConversionOutput,
 | 
						|
  EditorConfig,
 | 
						|
  LexicalCommand,
 | 
						|
  LexicalNode,
 | 
						|
  NodeKey,
 | 
						|
  RangeSelection,
 | 
						|
  SerializedElementNode,
 | 
						|
} from 'lexical';
 | 
						|
 | 
						|
import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils';
 | 
						|
import {
 | 
						|
  $applyNodeReplacement,
 | 
						|
  $getSelection,
 | 
						|
  $isElementNode,
 | 
						|
  $isRangeSelection,
 | 
						|
  createCommand,
 | 
						|
  ElementNode,
 | 
						|
  Spread,
 | 
						|
} from 'lexical';
 | 
						|
 | 
						|
export type LinkAttributes = {
 | 
						|
  rel?: null | string;
 | 
						|
  target?: null | string;
 | 
						|
  title?: null | string;
 | 
						|
};
 | 
						|
 | 
						|
export type AutoLinkAttributes = Partial<
 | 
						|
  Spread<LinkAttributes, {isUnlinked?: boolean}>
 | 
						|
>;
 | 
						|
 | 
						|
export type SerializedLinkNode = Spread<
 | 
						|
  {
 | 
						|
    url: string;
 | 
						|
  },
 | 
						|
  Spread<LinkAttributes, SerializedElementNode>
 | 
						|
>;
 | 
						|
 | 
						|
type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
 | 
						|
 | 
						|
const SUPPORTED_URL_PROTOCOLS = new Set([
 | 
						|
  'http:',
 | 
						|
  'https:',
 | 
						|
  'mailto:',
 | 
						|
  'sms:',
 | 
						|
  'tel:',
 | 
						|
]);
 | 
						|
 | 
						|
/** @noInheritDoc */
 | 
						|
export class LinkNode extends ElementNode {
 | 
						|
  /** @internal */
 | 
						|
  __url: string;
 | 
						|
  /** @internal */
 | 
						|
  __target: null | string;
 | 
						|
  /** @internal */
 | 
						|
  __rel: null | string;
 | 
						|
  /** @internal */
 | 
						|
  __title: null | string;
 | 
						|
 | 
						|
  static getType(): string {
 | 
						|
    return 'link';
 | 
						|
  }
 | 
						|
 | 
						|
  static clone(node: LinkNode): LinkNode {
 | 
						|
    return new LinkNode(
 | 
						|
      node.__url,
 | 
						|
      {rel: node.__rel, target: node.__target, title: node.__title},
 | 
						|
      node.__key,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
 | 
						|
    super(key);
 | 
						|
    const {target = null, rel = null, title = null} = attributes;
 | 
						|
    this.__url = url;
 | 
						|
    this.__target = target;
 | 
						|
    this.__rel = rel;
 | 
						|
    this.__title = title;
 | 
						|
  }
 | 
						|
 | 
						|
  createDOM(config: EditorConfig): LinkHTMLElementType {
 | 
						|
    const element = document.createElement('a');
 | 
						|
    element.href = this.sanitizeUrl(this.__url);
 | 
						|
    if (this.__target !== null) {
 | 
						|
      element.target = this.__target;
 | 
						|
    }
 | 
						|
    if (this.__rel !== null) {
 | 
						|
      element.rel = this.__rel;
 | 
						|
    }
 | 
						|
    if (this.__title !== null) {
 | 
						|
      element.title = this.__title;
 | 
						|
    }
 | 
						|
    addClassNamesToElement(element, config.theme.link);
 | 
						|
    return element;
 | 
						|
  }
 | 
						|
 | 
						|
  updateDOM(
 | 
						|
    prevNode: LinkNode,
 | 
						|
    anchor: LinkHTMLElementType,
 | 
						|
    config: EditorConfig,
 | 
						|
  ): boolean {
 | 
						|
    if (anchor instanceof HTMLAnchorElement) {
 | 
						|
      const url = this.__url;
 | 
						|
      const target = this.__target;
 | 
						|
      const rel = this.__rel;
 | 
						|
      const title = this.__title;
 | 
						|
      if (url !== prevNode.__url) {
 | 
						|
        anchor.href = url;
 | 
						|
      }
 | 
						|
 | 
						|
      if (target !== prevNode.__target) {
 | 
						|
        if (target) {
 | 
						|
          anchor.target = target;
 | 
						|
        } else {
 | 
						|
          anchor.removeAttribute('target');
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (rel !== prevNode.__rel) {
 | 
						|
        if (rel) {
 | 
						|
          anchor.rel = rel;
 | 
						|
        } else {
 | 
						|
          anchor.removeAttribute('rel');
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (title !== prevNode.__title) {
 | 
						|
        if (title) {
 | 
						|
          anchor.title = title;
 | 
						|
        } else {
 | 
						|
          anchor.removeAttribute('title');
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  static importDOM(): DOMConversionMap | null {
 | 
						|
    return {
 | 
						|
      a: (node: Node) => ({
 | 
						|
        conversion: $convertAnchorElement,
 | 
						|
        priority: 1,
 | 
						|
      }),
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  static importJSON(
 | 
						|
    serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
 | 
						|
  ): LinkNode {
 | 
						|
    const node = $createLinkNode(serializedNode.url, {
 | 
						|
      rel: serializedNode.rel,
 | 
						|
      target: serializedNode.target,
 | 
						|
      title: serializedNode.title,
 | 
						|
    });
 | 
						|
    node.setDirection(serializedNode.direction);
 | 
						|
    return node;
 | 
						|
  }
 | 
						|
 | 
						|
  sanitizeUrl(url: string): string {
 | 
						|
    try {
 | 
						|
      const parsedUrl = new URL(url);
 | 
						|
      // eslint-disable-next-line no-script-url
 | 
						|
      if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
 | 
						|
        return 'about:blank';
 | 
						|
      }
 | 
						|
    } catch {
 | 
						|
      return url;
 | 
						|
    }
 | 
						|
    return url;
 | 
						|
  }
 | 
						|
 | 
						|
  exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
 | 
						|
    return {
 | 
						|
      ...super.exportJSON(),
 | 
						|
      rel: this.getRel(),
 | 
						|
      target: this.getTarget(),
 | 
						|
      title: this.getTitle(),
 | 
						|
      type: 'link',
 | 
						|
      url: this.getURL(),
 | 
						|
      version: 1,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  getURL(): string {
 | 
						|
    return this.getLatest().__url;
 | 
						|
  }
 | 
						|
 | 
						|
  setURL(url: string): void {
 | 
						|
    const writable = this.getWritable();
 | 
						|
    writable.__url = url;
 | 
						|
  }
 | 
						|
 | 
						|
  getTarget(): null | string {
 | 
						|
    return this.getLatest().__target;
 | 
						|
  }
 | 
						|
 | 
						|
  setTarget(target: null | string): void {
 | 
						|
    const writable = this.getWritable();
 | 
						|
    writable.__target = target;
 | 
						|
  }
 | 
						|
 | 
						|
  getRel(): null | string {
 | 
						|
    return this.getLatest().__rel;
 | 
						|
  }
 | 
						|
 | 
						|
  setRel(rel: null | string): void {
 | 
						|
    const writable = this.getWritable();
 | 
						|
    writable.__rel = rel;
 | 
						|
  }
 | 
						|
 | 
						|
  getTitle(): null | string {
 | 
						|
    return this.getLatest().__title;
 | 
						|
  }
 | 
						|
 | 
						|
  setTitle(title: null | string): void {
 | 
						|
    const writable = this.getWritable();
 | 
						|
    writable.__title = title;
 | 
						|
  }
 | 
						|
 | 
						|
  insertNewAfter(
 | 
						|
    _: RangeSelection,
 | 
						|
    restoreSelection = true,
 | 
						|
  ): null | ElementNode {
 | 
						|
    const linkNode = $createLinkNode(this.__url, {
 | 
						|
      rel: this.__rel,
 | 
						|
      target: this.__target,
 | 
						|
      title: this.__title,
 | 
						|
    });
 | 
						|
    this.insertAfter(linkNode, restoreSelection);
 | 
						|
    return linkNode;
 | 
						|
  }
 | 
						|
 | 
						|
  canInsertTextBefore(): false {
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  canInsertTextAfter(): false {
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  canBeEmpty(): false {
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  isInline(): true {
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  extractWithChild(
 | 
						|
    child: LexicalNode,
 | 
						|
    selection: BaseSelection,
 | 
						|
    destination: 'clone' | 'html',
 | 
						|
  ): boolean {
 | 
						|
    if (!$isRangeSelection(selection)) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    const anchorNode = selection.anchor.getNode();
 | 
						|
    const focusNode = selection.focus.getNode();
 | 
						|
 | 
						|
    return (
 | 
						|
      this.isParentOf(anchorNode) &&
 | 
						|
      this.isParentOf(focusNode) &&
 | 
						|
      selection.getTextContent().length > 0
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  isEmailURI(): boolean {
 | 
						|
    return this.__url.startsWith('mailto:');
 | 
						|
  }
 | 
						|
 | 
						|
  isWebSiteURI(): boolean {
 | 
						|
    return (
 | 
						|
      this.__url.startsWith('https://') || this.__url.startsWith('http://')
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function $convertAnchorElement(domNode: Node): DOMConversionOutput {
 | 
						|
  let node = null;
 | 
						|
  if (isHTMLAnchorElement(domNode)) {
 | 
						|
    const content = domNode.textContent;
 | 
						|
    if ((content !== null && content !== '') || domNode.children.length > 0) {
 | 
						|
      node = $createLinkNode(domNode.getAttribute('href') || '', {
 | 
						|
        rel: domNode.getAttribute('rel'),
 | 
						|
        target: domNode.getAttribute('target'),
 | 
						|
        title: domNode.getAttribute('title'),
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
  return {node};
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Takes a URL and creates a LinkNode.
 | 
						|
 * @param url - The URL the LinkNode should direct to.
 | 
						|
 * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\}
 | 
						|
 * @returns The LinkNode.
 | 
						|
 */
 | 
						|
export function $createLinkNode(
 | 
						|
  url: string,
 | 
						|
  attributes?: LinkAttributes,
 | 
						|
): LinkNode {
 | 
						|
  return $applyNodeReplacement(new LinkNode(url, attributes));
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Determines if node is a LinkNode.
 | 
						|
 * @param node - The node to be checked.
 | 
						|
 * @returns true if node is a LinkNode, false otherwise.
 | 
						|
 */
 | 
						|
export function $isLinkNode(
 | 
						|
  node: LexicalNode | null | undefined,
 | 
						|
): node is LinkNode {
 | 
						|
  return node instanceof LinkNode;
 | 
						|
}
 | 
						|
 | 
						|
export type SerializedAutoLinkNode = Spread<
 | 
						|
  {
 | 
						|
    isUnlinked: boolean;
 | 
						|
  },
 | 
						|
  SerializedLinkNode
 | 
						|
>;
 | 
						|
 | 
						|
// Custom node type to override `canInsertTextAfter` that will
 | 
						|
// allow typing within the link
 | 
						|
export class AutoLinkNode extends LinkNode {
 | 
						|
  /** @internal */
 | 
						|
  /** Indicates whether the autolink was ever unlinked. **/
 | 
						|
  __isUnlinked: boolean;
 | 
						|
 | 
						|
  constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
 | 
						|
    super(url, attributes, key);
 | 
						|
    this.__isUnlinked =
 | 
						|
      attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
 | 
						|
        ? attributes.isUnlinked
 | 
						|
        : false;
 | 
						|
  }
 | 
						|
 | 
						|
  static getType(): string {
 | 
						|
    return 'autolink';
 | 
						|
  }
 | 
						|
 | 
						|
  static clone(node: AutoLinkNode): AutoLinkNode {
 | 
						|
    return new AutoLinkNode(
 | 
						|
      node.__url,
 | 
						|
      {
 | 
						|
        isUnlinked: node.__isUnlinked,
 | 
						|
        rel: node.__rel,
 | 
						|
        target: node.__target,
 | 
						|
        title: node.__title,
 | 
						|
      },
 | 
						|
      node.__key,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  getIsUnlinked(): boolean {
 | 
						|
    return this.__isUnlinked;
 | 
						|
  }
 | 
						|
 | 
						|
  setIsUnlinked(value: boolean) {
 | 
						|
    const self = this.getWritable();
 | 
						|
    self.__isUnlinked = value;
 | 
						|
    return self;
 | 
						|
  }
 | 
						|
 | 
						|
  createDOM(config: EditorConfig): LinkHTMLElementType {
 | 
						|
    if (this.__isUnlinked) {
 | 
						|
      return document.createElement('span');
 | 
						|
    } else {
 | 
						|
      return super.createDOM(config);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  updateDOM(
 | 
						|
    prevNode: AutoLinkNode,
 | 
						|
    anchor: LinkHTMLElementType,
 | 
						|
    config: EditorConfig,
 | 
						|
  ): boolean {
 | 
						|
    return (
 | 
						|
      super.updateDOM(prevNode, anchor, config) ||
 | 
						|
      prevNode.__isUnlinked !== this.__isUnlinked
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
 | 
						|
    const node = $createAutoLinkNode(serializedNode.url, {
 | 
						|
      isUnlinked: serializedNode.isUnlinked,
 | 
						|
      rel: serializedNode.rel,
 | 
						|
      target: serializedNode.target,
 | 
						|
      title: serializedNode.title,
 | 
						|
    });
 | 
						|
    node.setDirection(serializedNode.direction);
 | 
						|
    return node;
 | 
						|
  }
 | 
						|
 | 
						|
  static importDOM(): null {
 | 
						|
    // TODO: Should link node should handle the import over autolink?
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
 | 
						|
  exportJSON(): SerializedAutoLinkNode {
 | 
						|
    return {
 | 
						|
      ...super.exportJSON(),
 | 
						|
      isUnlinked: this.__isUnlinked,
 | 
						|
      type: 'autolink',
 | 
						|
      version: 1,
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  insertNewAfter(
 | 
						|
    selection: RangeSelection,
 | 
						|
    restoreSelection = true,
 | 
						|
  ): null | ElementNode {
 | 
						|
    const element = this.getParentOrThrow().insertNewAfter(
 | 
						|
      selection,
 | 
						|
      restoreSelection,
 | 
						|
    );
 | 
						|
    if ($isElementNode(element)) {
 | 
						|
      const linkNode = $createAutoLinkNode(this.__url, {
 | 
						|
        isUnlinked: this.__isUnlinked,
 | 
						|
        rel: this.__rel,
 | 
						|
        target: this.__target,
 | 
						|
        title: this.__title,
 | 
						|
      });
 | 
						|
      element.append(linkNode);
 | 
						|
      return linkNode;
 | 
						|
    }
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
 | 
						|
 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
 | 
						|
 * @param url - The URL the LinkNode should direct to.
 | 
						|
 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
 | 
						|
 * @returns The LinkNode.
 | 
						|
 */
 | 
						|
export function $createAutoLinkNode(
 | 
						|
  url: string,
 | 
						|
  attributes?: AutoLinkAttributes,
 | 
						|
): AutoLinkNode {
 | 
						|
  return $applyNodeReplacement(new AutoLinkNode(url, attributes));
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Determines if node is an AutoLinkNode.
 | 
						|
 * @param node - The node to be checked.
 | 
						|
 * @returns true if node is an AutoLinkNode, false otherwise.
 | 
						|
 */
 | 
						|
export function $isAutoLinkNode(
 | 
						|
  node: LexicalNode | null | undefined,
 | 
						|
): node is AutoLinkNode {
 | 
						|
  return node instanceof AutoLinkNode;
 | 
						|
}
 | 
						|
 | 
						|
export const TOGGLE_LINK_COMMAND: LexicalCommand<
 | 
						|
  string | ({url: string} & LinkAttributes) | null
 | 
						|
> = createCommand('TOGGLE_LINK_COMMAND');
 | 
						|
 | 
						|
/**
 | 
						|
 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
 | 
						|
 * but saves any children and brings them up to the parent node.
 | 
						|
 * @param url - The URL the link directs to.
 | 
						|
 * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\}
 | 
						|
 */
 | 
						|
export function $toggleLink(
 | 
						|
  url: null | string,
 | 
						|
  attributes: LinkAttributes = {},
 | 
						|
): void {
 | 
						|
  const {target, title} = attributes;
 | 
						|
  const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
 | 
						|
  const selection = $getSelection();
 | 
						|
 | 
						|
  if (!$isRangeSelection(selection)) {
 | 
						|
    return;
 | 
						|
  }
 | 
						|
  const nodes = selection.extract();
 | 
						|
 | 
						|
  if (url === null) {
 | 
						|
    // Remove LinkNodes
 | 
						|
    nodes.forEach((node) => {
 | 
						|
      const parent = node.getParent();
 | 
						|
 | 
						|
      if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) {
 | 
						|
        const children = parent.getChildren();
 | 
						|
 | 
						|
        for (let i = 0; i < children.length; i++) {
 | 
						|
          parent.insertBefore(children[i]);
 | 
						|
        }
 | 
						|
 | 
						|
        parent.remove();
 | 
						|
      }
 | 
						|
    });
 | 
						|
  } else {
 | 
						|
    // Add or merge LinkNodes
 | 
						|
    if (nodes.length === 1) {
 | 
						|
      const firstNode = nodes[0];
 | 
						|
      // if the first node is a LinkNode or if its
 | 
						|
      // parent is a LinkNode, we update the URL, target and rel.
 | 
						|
      const linkNode = $getAncestor(firstNode, $isLinkNode);
 | 
						|
      if (linkNode !== null) {
 | 
						|
        linkNode.setURL(url);
 | 
						|
        if (target !== undefined) {
 | 
						|
          linkNode.setTarget(target);
 | 
						|
        }
 | 
						|
        if (rel !== null) {
 | 
						|
          linkNode.setRel(rel);
 | 
						|
        }
 | 
						|
        if (title !== undefined) {
 | 
						|
          linkNode.setTitle(title);
 | 
						|
        }
 | 
						|
        return;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    let prevParent: ElementNode | LinkNode | null = null;
 | 
						|
    let linkNode: LinkNode | null = null;
 | 
						|
 | 
						|
    nodes.forEach((node) => {
 | 
						|
      const parent = node.getParent();
 | 
						|
 | 
						|
      if (
 | 
						|
        parent === linkNode ||
 | 
						|
        parent === null ||
 | 
						|
        ($isElementNode(node) && !node.isInline())
 | 
						|
      ) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if ($isLinkNode(parent)) {
 | 
						|
        linkNode = parent;
 | 
						|
        parent.setURL(url);
 | 
						|
        if (target !== undefined) {
 | 
						|
          parent.setTarget(target);
 | 
						|
        }
 | 
						|
        if (rel !== null) {
 | 
						|
          linkNode.setRel(rel);
 | 
						|
        }
 | 
						|
        if (title !== undefined) {
 | 
						|
          linkNode.setTitle(title);
 | 
						|
        }
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (!parent.is(prevParent)) {
 | 
						|
        prevParent = parent;
 | 
						|
        linkNode = $createLinkNode(url, {rel, target, title});
 | 
						|
 | 
						|
        if ($isLinkNode(parent)) {
 | 
						|
          if (node.getPreviousSibling() === null) {
 | 
						|
            parent.insertBefore(linkNode);
 | 
						|
          } else {
 | 
						|
            parent.insertAfter(linkNode);
 | 
						|
          }
 | 
						|
        } else {
 | 
						|
          node.insertBefore(linkNode);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if ($isLinkNode(node)) {
 | 
						|
        if (node.is(linkNode)) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        if (linkNode !== null) {
 | 
						|
          const children = node.getChildren();
 | 
						|
 | 
						|
          for (let i = 0; i < children.length; i++) {
 | 
						|
            linkNode.append(children[i]);
 | 
						|
          }
 | 
						|
        }
 | 
						|
 | 
						|
        node.remove();
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (linkNode !== null) {
 | 
						|
        linkNode.append(node);
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
}
 | 
						|
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
 | 
						|
export const toggleLink = $toggleLink;
 | 
						|
 | 
						|
function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
 | 
						|
  node: LexicalNode,
 | 
						|
  predicate: (ancestor: LexicalNode) => ancestor is NodeType,
 | 
						|
) {
 | 
						|
  let parent = node;
 | 
						|
  while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
 | 
						|
    parent = parent.getParentOrThrow();
 | 
						|
  }
 | 
						|
  return predicate(parent) ? parent : null;
 | 
						|
}
 |