Eine aufbereitete Darstellung der Quelle

 
     
 
 
Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 

Benutzer

Quelle  multiline-editor.mjs   Sprache: unbekannt

 
Spracherkennung für: .mjs vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { css, html } from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
import {
  Decoration,
  DecorationSet,
  EditorState,
  EditorView,
  MarkdownParser,
  MarkdownSerializer,
  Plugin as PmPlugin,
  Schema,
  TextSelection,
  baseKeymap,
  basicSchema,
  defaultMarkdownParser,
  defaultMarkdownSerializer,
  history as historyPlugin,
  keymap,
  redo as historyRedo,
  undo as historyUndo,
} from "chrome://browser/content/multilineeditor/prosemirror.bundle.mjs";

function generatePlaceholderKeyframeStyles(placeholderCount) {
  const segmentDuration = 100 / placeholderCount;
  const transitionDuration = segmentDuration * 0.2;
  const exitStart = Math.round(segmentDuration - transitionDuration);
  const exitEnd = Math.round(segmentDuration);
  const enterStart = Math.round(100 - transitionDuration);

  return css`
    @keyframes multiline-editor-placeholder-animation {
      0% {
        opacity: 1;
        transform: translateY(0);
        visibility: visible;
      }
      ${exitStart}% {
        opacity: 1;
        transform: translateY(0);
        visibility: visible;
      }
      ${exitEnd}% {
        opacity: 0;
        transform: translateY(-100%);
        visibility: hidden;
      }
      ${enterStart}% {
        opacity: 0;
        transform: translateY(100%);
        visibility: hidden;
      }
      100% {
        opacity: 1;
        transform: translateY(0);
        visibility: visible;
      }
    }
  `;
}

/**
 * @typedef {object} MultilineEditorPlugin
 * @property {object} [schemaExtension] - Schema extensions
 * @property {object} [schemaExtension.nodes] - Node specs
 * @property {object} [schemaExtension.marks] - Mark specs
 * @property {function(MultilineEditor): PmPlugin} [createPlugin] - Create plugin
 * @property {object} [parseMarkdown] - Markdown token specs
 * @property {object} [toMarkdown] - Markdown serializer functions
 */

/**
 * @class MultilineEditor
 *
 * A ProseMirror-based multiline editor.
 *
 * @property {string} placeholder - Placeholder text for the editor.
 * @property {Array<MultilineEditorPlugin>} plugins - Editor plugins.
 * @property {boolean} readOnly - Whether the editor is read-only.
 */
export class MultilineEditor extends MozLitElement {
  static shadowRootOptions = {
    ...MozLitElement.shadowRootOptions,
    delegatesFocus: true,
  };

  static properties = {
    placeholder: { type: String, reflect: true, fluent: true },
    placeholderHints: { type: Array, attribute: false },
    readOnly: { type: Boolean, reflect: true, attribute: "readonly" },
    plugins: { type: Array, attribute: false },
    maxLength: { type: Number, attribute: "maxlength" },
    // ARIA combobox state forwarded from the host to the inner contenteditable.
    // reflect:false so the host attribute stays the source of truth.
    ariaControls: { type: String, attribute: "aria-controls", reflect: false },
    ariaAutoComplete: {
      type: String,
      attribute: "aria-autocomplete",
      reflect: false,
    },
    ariaExpanded: { type: String, attribute: "aria-expanded", reflect: false },
    ariaActiveDescendant: {
      type: String,
      attribute: "aria-activedescendant",
      reflect: false,
    },
    ariaHasPopup: { type: String, attribute: "aria-haspopup", reflect: false },
    showPlaceholderAnimation: {
      type: Boolean,
      reflect: true,
      attribute: "show-placeholder-animation",
    },
  };

  static schema = new Schema({
    nodes: {
      doc: basicSchema.spec.nodes.get("doc"),
      paragraph: basicSchema.spec.nodes.get("paragraph"),
      text: basicSchema.spec.nodes.get("text"),
    },
  });

  /**
   * ARIA state forwarded from the host's reactive Lit properties to the inner
   * contenteditable. Each entry pairs the property name (used in `updated()`
   * change detection) with the attribute name applied to the inner element.
   */
  static FORWARDED_ARIA = [
    { prop: "ariaControls", attr: "aria-controls" },
    { prop: "ariaAutoComplete", attr: "aria-autocomplete" },
    { prop: "ariaExpanded", attr: "aria-expanded" },
    { prop: "ariaActiveDescendant", attr: "aria-activedescendant" },
    { prop: "ariaHasPopup", attr: "aria-haspopup" },
  ];

  #instanceId = crypto.randomUUID();
  #placeholderVersion = 0;
  #placeholderKeyframeStyles = null;
  #pendingValue = "";
  #placeholderPlugin;
  #plugins;
  #suppressInputEvent = false;
  #view;
  #markdownSerializer;
  #innerRole = null;

  constructor() {
    super();

    this.placeholder = "";
    this.placeholderHints = [];
    this.plugins = [];
    this.readOnly = false;
    this.maxLength = 0;
    this.showPlaceholderAnimation = false;
    this.#placeholderPlugin = this.#createPlaceholderPlugin();
    const plugins = [
      historyPlugin(),
      keymap({
        Enter: () => true,
        "Shift-Enter": (state, dispatch) =>
          this.#insertParagraph(state, dispatch),
        "Mod-z": historyUndo,
        "Mod-y": historyRedo,
        "Shift-Mod-z": historyRedo,
      }),
      keymap(baseKeymap),
      this.#placeholderPlugin,
    ];

    if (document.contentType === "application/xhtml+xml") {
      plugins.push(this.#createCleanupOrphanedBreaksPlugin());
    }

    this.#plugins = plugins;
  }

  /**
   * The ProseMirror view instance.
   *
   * @type {EditorView}
   */
  get view() {
    return this.#view;
  }

  /**
   * Whether the editor is composing.
   *
   * @type {boolean}
   */
  get composing() {
    return this.#view?.composing ?? false;
  }

  /**
   * The current text content of the editor.
   *
   * @type {string}
   */
  get value() {
    if (!this.#view) {
      return this.#pendingValue;
    }
    if (this.#markdownSerializer) {
      return this.#markdownSerializer.serialize(this.#view.state.doc);
    }
    return this.#view.state.doc.textBetween(
      0,
      this.#view.state.doc.content.size,
      "\n",
      "\n"
    );
  }

  /**
   * Set the text content of the editor.
   *
   * @param {string} val
   */
  set value(val) {
    if (!this.#view) {
      this.#pendingValue = val;
      return;
    }

    if (val === this.value) {
      return;
    }

    const state = this.#view.state;
    const doc = this.#textToDoc(val, state.schema);

    const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content);
    tr.setMeta("addToHistory", false);

    const cursorPos = this.#posFromTextOffset(val.length, tr.doc);
    // Suppress input events when updating only the text selection.
    this.#suppressInputEvent = true;
    try {
      this.#view.dispatch(
        tr.setSelection(
          TextSelection.between(
            tr.doc.resolve(cursorPos),
            tr.doc.resolve(cursorPos)
          )
        )
      );
    } finally {
      this.#suppressInputEvent = false;
    }
  }

  /**
   * The plain-text content of the editor with atom nodes (e.g. mentions)
   * contributing zero characters. Useful for persisting input alongside a
   * structured list of inline atoms.
   *
   * @type {string}
   */
  get plainText() {
    if (!this.#view) {
      return this.#pendingValue;
    }
    return this.#view.state.doc.textBetween(
      0,
      this.#view.state.doc.content.size,
      "\n",
      ""
    );
  }

  /**
   * Convert a ProseMirror document position to a text-character offset that
   * matches `plainText`.
   *
   * @param {number} pos
   * @returns {number}
   */
  posToTextOffset(pos) {
    return this.#textOffsetFromPos(pos, this.#view?.state.doc, "\n", "");
  }

  /**
   * Convert a text-character offset (matching `plainText`) to a ProseMirror
   * document position.
   *
   * @param {number} offset
   * @returns {number}
   */
  textOffsetToPos(offset) {
    return this.#posFromTextOffset(offset);
  }

  /**
   * The start offset of the selection.
   *
   * @type {number}
   */
  get selectionStart() {
    if (!this.#view) {
      return 0;
    }
    return this.#textOffsetFromPos(this.#view.state.selection.from);
  }

  /**
   * Set the start offset of the selection.
   *
   * @param {number} val
   */
  set selectionStart(val) {
    this.setSelectionRange(val, this.selectionEnd ?? val);
  }

  /**
   * The end offset of the selection.
   *
   * @type {number}
   */
  get selectionEnd() {
    if (!this.#view) {
      return 0;
    }
    return this.#textOffsetFromPos(this.#view.state.selection.to);
  }

  /**
   * Set the end offset of the selection.
   *
   * @param {number} val
   */
  set selectionEnd(val) {
    this.setSelectionRange(this.selectionStart ?? 0, val);
  }

  /**
   * Set the selection range in the editor.
   *
   * @param {number} start
   * @param {number} end
   */
  setSelectionRange(start, end) {
    if (!this.#view) {
      return;
    }

    const doc = this.#view.state.doc;
    const docSize = doc.content.size;
    const maxOffset = this.#textLength(doc);
    const fromOffset = Math.max(0, Math.min(start ?? 0, maxOffset));
    const toOffset = Math.max(0, Math.min(end ?? fromOffset, maxOffset));
    const from = Math.max(
      0,
      Math.min(this.#posFromTextOffset(fromOffset, doc), docSize)
    );
    const to = Math.max(
      0,
      Math.min(this.#posFromTextOffset(toOffset, doc), docSize)
    );

    if (
      this.#view.state.selection.from === from &&
      this.#view.state.selection.to === to
    ) {
      return;
    }

    let selection;
    try {
      selection = TextSelection.between(doc.resolve(from), doc.resolve(to));
    } catch (_e) {
      const anchor = Math.max(0, Math.min(to, docSize));
      selection = TextSelection.near(doc.resolve(anchor));
    }
    this.#view.dispatch(
      this.#view.state.tr.setSelection(selection).scrollIntoView()
    );
    this.#dispatchSelectionChange();
  }

  /**
   * Select all text in the editor.
   */
  select() {
    this.setSelectionRange(0, this.value.length);
    this.focus();
  }

  /**
   * Focus the editor.
   */
  focus() {
    this.#view?.focus();
    super.focus();
  }

  /**
   * Called when the element is added to the DOM.
   */
  connectedCallback() {
    super.connectedCallback();
    // Capture a consumer-supplied role for the inner contenteditable before
    // hiding this host element from the accessibility tree. With
    // delegatesFocus, screen readers focus the inner contenteditable, so its
    // role and ARIA state are what get announced.
    const hostRole = this.getAttribute("role");
    if (hostRole && hostRole !== "presentation") {
      this.#innerRole = hostRole;
    }
    this.setAttribute("role", "presentation");
  }

  /**
   * Called when the element is removed from the DOM.
   */
  disconnectedCallback() {
    this.#cancelPlaceholderAnimations();
    this.#destroyView();
    this.#pendingValue = "";
    super.disconnectedCallback();
  }

  /**
   * Called after the element’s DOM has been rendered for the first time.
   */
  firstUpdated() {
    this.#updatePlaceholderKeyframeStyles();
    this.#createView();
  }

  /**
   * Called when the element’s properties are updated.
   *
   * @param {Map} changedProps
   */
  updated(changedProps) {
    if (
      changedProps.has("placeholder") ||
      changedProps.has("placeholderHints") ||
      changedProps.has("plugins") ||
      changedProps.has("readOnly") ||
      MultilineEditor.FORWARDED_ARIA.some(({ prop }) => changedProps.has(prop))
    ) {
      this.#refreshView();
    }
    if (changedProps.has("showPlaceholderAnimation")) {
      // Restart or cancel placeholder animation.
      if (this.showPlaceholderAnimation) {
        this.#resetPlaceholderAnimation();
      } else {
        this.#cancelPlaceholderAnimations();
      }
    }
  }

  #buildSchema() {
    const nodes = {};
    const marks = {};

    for (const plugin of this.plugins) {
      Object.assign(nodes, plugin.schemaExtension?.nodes ?? {});
      Object.assign(marks, plugin.schemaExtension?.marks ?? {});
    }

    if (Object.keys(nodes).length === 0 && Object.keys(marks).length === 0) {
      return MultilineEditor.schema;
    }

    const baseSchemaSpec = MultilineEditor.schema.spec;
    return new Schema({
      nodes: baseSchemaSpec.nodes.append(nodes),
      marks: baseSchemaSpec.marks.append(marks),
    });
  }

  #createMarkdownClipboardPlugin(schema) {
    const parsers = {};
    const serializers = {};

    for (const plugin of this.plugins) {
      Object.assign(parsers, plugin.parseMarkdown ?? {});
      Object.assign(serializers, plugin.toMarkdown ?? {});
    }

    if (
      Object.keys(parsers).length === 0 &&
      Object.keys(serializers).length === 0
    ) {
      return null;
    }

    // Filter parser tokens that reference nodes or marks not in the schema.
    const filterParserTokens = tokens => {
      const filtered = {};
      for (const [key, spec] of Object.entries(tokens)) {
        if (
          Object.values(spec).every(
            val =>
              typeof val !== "string" || schema.nodes[val] || schema.marks[val]
          )
        ) {
          filtered[key] = spec;
        }
      }
      return filtered;
    };

    const filterBySchema = (items, schemaItems) => {
      const filtered = {};
      for (const [key, value] of Object.entries(items)) {
        if (schemaItems[key]) {
          filtered[key] = value;
        }
      }
      return filtered;
    };

    const parser = new MarkdownParser(schema, defaultMarkdownParser.tokenizer, {
      ...filterParserTokens(defaultMarkdownParser.tokens),
      ...parsers,
    });
    const serializer = new MarkdownSerializer(
      {
        ...defaultMarkdownSerializer.nodes,
        ...filterBySchema(serializers, schema.nodes),
      },
      {
        ...defaultMarkdownSerializer.marks,
        ...filterBySchema(serializers, schema.marks),
      }
    );
    this.#markdownSerializer = serializer;

    return new PmPlugin({
      props: {
        clipboardTextParser: text => parser.parse(text)?.content,
        clipboardTextSerializer: slice => serializer.serialize(slice.content),
      },
    });
  }

  #createView() {
    const mount = this.renderRoot.querySelector(".multiline-editor");
    if (!mount) {
      return;
    }

    const schema = this.#buildSchema();
    const markdownPlugin = this.#createMarkdownClipboardPlugin(schema);
    const customPlugins = this.plugins
      .map(plugin => plugin.createPlugin?.(this))
      .filter(Boolean);

    const state = EditorState.create({
      schema,
      plugins: [
        ...this.#plugins,
        ...(markdownPlugin ? [markdownPlugin] : []),
        ...customPlugins,
      ],
    });

    this.#view = new EditorView(mount, {
      state,
      attributes: this.#viewAttributes(),
      editable: () => !this.readOnly,
      dispatchTransaction: this.#dispatchTransaction,
      nodeViews: Object.assign(
        {},
        ...this.plugins.map(plugin => plugin.nodeViews).filter(Boolean)
      ),
    });

    if (this.#pendingValue) {
      this.value = this.#pendingValue;
      this.#pendingValue = "";
    }
  }

  #destroyView() {
    this.#view?.destroy();
    this.#view = null;
  }

  #dispatchTransaction = tr => {
    if (!this.#view) {
      return;
    }

    if (
      tr.docChanged &&
      this.maxLength > 0 &&
      tr.doc.content.size > this.maxLength
    ) {
      const newText = tr.doc.textBetween(0, tr.doc.content.size, "\n", "\n");
      const truncated = newText.slice(0, this.maxLength);

      /* tr.doc.content.size includes ProseMirror structural tokens, so check actual text length here */
      if (newText.length > this.maxLength && truncated !== this.value) {
        const doc = this.#textToDoc(truncated, this.#view.state.schema);
        tr = this.#view.state.tr
          .replaceWith(0, this.#view.state.doc.content.size, doc.content)
          .scrollIntoView();
      }
    }

    const prevText = this.value;
    const prevSelection = this.#view.state.selection;
    const nextState = this.#view.state.apply(tr);
    this.#view.updateState(nextState);

    const selectionChanged =
      tr.selectionSet &&
      (prevSelection.from !== nextState.selection.from ||
        prevSelection.to !== nextState.selection.to);

    if (selectionChanged) {
      this.#dispatchSelectionChange();
    }

    if (tr.docChanged && !this.#suppressInputEvent) {
      const nextText = this.value;
      let insertedText = "";
      for (const step of tr.steps) {
        insertedText += step.slice?.content?.textBetween(
          0,
          step.slice.content.size,
          "",
          ""
        );
      }
      this.dispatchEvent(
        new InputEvent("input", {
          bubbles: true,
          composed: true,
          data: insertedText || null,
          inputType:
            insertedText || nextText.length >= prevText.length
              ? "insertText"
              : "deleteContentBackward",
        })
      );
    }
  };

  #dispatchSelectionChange() {
    this.dispatchEvent(
      new Event("selectionchange", { bubbles: true, composed: true })
    );
  }

  #insertParagraph(state, dispatch) {
    const paragraph = state.schema.nodes.paragraph;
    if (!paragraph) {
      return false;
    }
    const { $from } = state.selection;
    let tr = state.tr;
    if (!state.selection.empty) {
      tr = tr.deleteSelection();
    }
    tr = tr.split(tr.mapping.map($from.pos)).scrollIntoView();
    dispatch(tr);
    return true;
  }

  #createPlaceholderHints() {
    const placeholderCount = this.placeholderHints.length + 1;

    const hints = document.createElement("ul");
    hints.id = `placeholder-hints-${this.#instanceId}-${this.#placeholderVersion}`;
    hints.className = "placeholder-hints";
    hints.setAttribute("role", "presentation");
    hints.style.setProperty(
      "--multiline-editor-placeholder-count",
      placeholderCount
    );

    const placeholder = document.createElement("li");
    placeholder.textContent = this.placeholder;
    placeholder.style.setProperty("--multiline-editor-placeholder-index", 0);
    hints.appendChild(placeholder);

    for (const [index, hint] of this.placeholderHints.entries()) {
      const hintItem = document.createElement("li");
      hintItem.textContent = hint;
      hintItem.style.setProperty(
        "--multiline-editor-placeholder-index",
        index + 1
      );
      hints.appendChild(hintItem);
    }

    return hints;
  }

  /**
   * Creates a plugin that shows a placeholder when the editor is empty.
   *
   * @returns {PmPlugin}
   */
  #createPlaceholderPlugin() {
    return new PmPlugin({
      props: {
        decorations: ({ doc }) => {
          if (
            doc.childCount !== 1 ||
            !doc.firstChild.isTextblock ||
            doc.firstChild.content.size !== 0
          ) {
            return null;
          }

          if (this.placeholderHints.length) {
            return DecorationSet.create(doc, [
              Decoration.widget(1, () => this.#createPlaceholderHints(), {
                key: `placeholder-hints-${this.#instanceId}-${this.#placeholderVersion}`,
                side: -1,
                ignoreSelection: true,
              }),
            ]);
          }

          if (!this.placeholder) {
            return null;
          }

          return DecorationSet.create(doc, [
            Decoration.node(0, doc.firstChild.nodeSize, {
              class: "placeholder",
              "data-placeholder": this.placeholder,
            }),
          ]);
        },
      },
    });
  }

  /**
   * Creates a plugin that removes orphaned hard breaks from empty paragraphs.
   *
   * In XHTML contexts the trailing break element in paragraphs are rendered as
   * uppercase (<BR> instead of <br>). ProseMirror seems to have issues parsing
   * these breaks, which leads to orphaned breaks after deleting text content.
   *
   * @returns {PmPlugin}
   */
  #createCleanupOrphanedBreaksPlugin() {
    return new PmPlugin({
      appendTransaction(transactions, prevState, nextState) {
        if (!transactions.some(tr => tr.docChanged)) {
          return null;
        }

        const tr = nextState.tr;
        let modified = false;

        nextState.doc.descendants((nextNode, nextPos) => {
          if (
            nextNode.type.name !== "paragraph" ||
            nextNode.textContent ||
            nextNode.childCount === 0
          ) {
            return true;
          }

          for (let i = 0; i < nextNode.childCount; i++) {
            if (nextNode.child(i).type.name === "hard_break") {
              const prevNode = prevState.doc.nodeAt(nextPos);
              if (prevNode?.type.name === "paragraph" && prevNode.textContent) {
                tr.replaceWith(
                  nextPos + 1,
                  nextPos + nextNode.content.size + 1,
                  []
                );
                modified = true;
              }
              break;
            }
          }

          return true;
        });

        return modified ? tr : null;
      },
    });
  }

  #refreshView() {
    if (!this.#view) {
      return;
    }

    this.#placeholderVersion++;
    this.#updatePlaceholderKeyframeStyles();
    this.#view.setProps({
      attributes: this.#viewAttributes(),
      editable: () => !this.readOnly,
    });
    this.#view.dispatch(this.#view.state.tr);
  }

  /**
   * Resets placeholder animations to their starting position.
   *
   * When the placeholder animation is re-enabled the cached placeholder
   * elements retain animation state and must be reset.
   */
  #resetPlaceholderAnimation() {
    const animations = this.#getPlaceholderAnimations();
    // The `ready` promise rejects with an AbortError if the animation is
    // cancelled before it becomes ready.
    Promise.all(animations.map(animation => animation.ready))
      .then(() => {
        for (const animation of animations) {
          animation.currentTime = 0;
        }
      })
      .catch(() => {});
  }

  #cancelPlaceholderAnimations() {
    const animations = this.#getPlaceholderAnimations();
    for (const animation of animations) {
      animation.cancel();
    }
  }

  #getPlaceholderAnimations() {
    const placeholderHints =
      this.renderRoot.querySelector(".placeholder-hints");
    if (!placeholderHints) {
      return [];
    }
    return [...placeholderHints.querySelectorAll("li")].flatMap(
      placeholderHint => placeholderHint.getAnimations()
    );
  }

  #updatePlaceholderKeyframeStyles() {
    if (!this.placeholderHints.length) {
      return;
    }
    if (!this.#placeholderKeyframeStyles) {
      this.#placeholderKeyframeStyles = new CSSStyleSheet();
      this.renderRoot.adoptedStyleSheets.push(this.#placeholderKeyframeStyles);
    }
    const keyframeStyles = generatePlaceholderKeyframeStyles(
      this.placeholderHints.length + 1
    );
    this.#placeholderKeyframeStyles.replaceSync(keyframeStyles.cssText);
  }

  #textToDoc(text, schema) {
    const paragraphs = text.split("\n").map(line => {
      const content = line ? [schema.text(line)] : [];
      return schema.node("paragraph", null, content);
    });
    return schema.node("doc", null, paragraphs);
  }

  #textOffsetFromPos(
    pos,
    doc = this.#view?.state.doc,
    blockSeparator = "\n",
    leafText = "\n"
  ) {
    if (!doc) {
      return 0;
    }
    return doc.textBetween(0, pos, blockSeparator, leafText).length;
  }

  #posFromTextOffset(offset, doc = this.#view?.state.doc) {
    if (!doc) {
      return 0;
    }
    const target = Math.max(0, Math.min(offset ?? 0, this.#textLength(doc)));
    let seen = 0;
    let pos = doc.content.size;
    let found = false;
    let paragraphCount = 0;
    doc.descendants((node, nodePos) => {
      if (found) {
        return false;
      }
      if (node.type.name === "paragraph") {
        if (paragraphCount > 0) {
          if (target <= seen + 1) {
            pos = nodePos;
            found = true;
            return false;
          }
          seen += 1;
        }
        paragraphCount++;
      }
      if (node.isText) {
        const textNodeLength = node.text.length;
        const start = nodePos;
        if (target <= seen + textNodeLength) {
          pos = start + (target - seen);
          found = true;
          return false;
        }
        seen += textNodeLength;
      } else if (node.type.name === "hard_break") {
        if (target <= seen + 1) {
          pos = nodePos;
          found = true;
          return false;
        }
        seen += 1;
      }
      return true;
    });
    return pos;
  }

  #textLength(doc) {
    if (!doc) {
      return 0;
    }
    return doc.textBetween(0, doc.content.size, "\n", "\n").length;
  }

  #viewAttributes() {
    const attrs = {
      "aria-label": this.placeholder,
      "aria-readonly": this.readOnly ? "true" : "false",
    };

    if (this.#innerRole) {
      // Combobox is a single-line widget per ARIA; omit aria-multiline so the
      // role's semantics aren't muddled.
      attrs.role = this.#innerRole;
    } else {
      attrs.role = "textbox";
      attrs["aria-multiline"] = "true";
    }

    for (const { prop, attr } of MultilineEditor.FORWARDED_ARIA) {
      const value = this[prop];
      if (value != null) {
        attrs[attr] = value;
      }
    }

    if (this.placeholderHints.length) {
      attrs["aria-describedby"] =
        `placeholder-hints-${this.#instanceId}-${this.#placeholderVersion}`;
    }
    return attrs;
  }

  render() {
    return html`
      <link
        rel="stylesheet"
        href="chrome://browser/content/multilineeditor/prosemirror.css"
      />
      <link
        rel="stylesheet"
        href="chrome://browser/content/multilineeditor/multiline-editor.css"
      />
      <div class="multiline-editor"></div>
    `;
  }
}

customElements.define("moz-multiline-editor", MultilineEditor);

[Dauer der Verarbeitung: 0.41 Sekunden]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Open Source Software

     Quellcodebibliothek
     Eigene Quellcodes
     Fremde Quellcodes
     Suchen

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Statistik
#Sources=277311
#Domains=752002