<body>
  <prosemirror-editor
    change-event="custom-change-event-name"
    html="
      <p>Lorem ipsum dolor sit amet, <a href='/this'>consectetur</a> adipiscing elit. Etiam efficitur, diam eu hendrerit dapibus, turpis lorem convallis felis, nec volutpat elit enim nec orci. Aliquam eget cursus nulla. Maecenas quis mauris turpis. Nullam id faucibus ex. Nulla cursus velit nibh, sit amet maximus dolor pulvinar non. Quisque ut efficitur odio. Phasellus vitae justo a purus semper imperdiet.</p>
      <p>Nunc <a href='/this'>cursus</a>, odio nec egestas eleifend, dui enim porttitor dui, quis bibendum arcu felis eget ante. Donec vulputate massa velit, nec tempus dolor pulvinar non. Sed ultrices velit et tempus fermentum. Morbi interdum id est sit amet efficitur. Suspendisse venenatis in augue nec commodo. Ut vehicula eros vitae leo commodo cursus. Mauris viverra sodales massa, a bibendum nisl malesuada ultrices. Morbi pulvinar urna nec justo pharetra eleifend. Nunc aliquet diam malesuada dui placerat venenatis. Sed at libero rutrum, aliquam turpis eget, imperdiet nisl.</p>"
  ></prosemirror-editor>
</body>
<script>
  document.querySelector("prosemirror-editor").addEventListener("custom-change-event-name", (e) => {
    console.log(e.detail.html);
    console.log(e.detail.json);
  });
</script>
<script async type="module" src="https://esm.sh/es-module-shims@1.9.0"></script>
<script type="importmap">
  {
    "imports": {
      "prosemirror-model": "https://esm.sh/*prosemirror-model@1.19.4",
      "orderedmap": "https://esm.sh/*orderedmap@2.1.1",
      "prosemirror-schema-basic": "https://esm.sh/*prosemirror-schema-basic@1.2.2",
      "prosemirror-state": "https://esm.sh/*prosemirror-state@1.4.3",
      "prosemirror-transform": "https://esm.sh/*prosemirror-transform@1.8.0",
      "prosemirror-view": "https://esm.sh/*prosemirror-view@1.33.3",
      "rope-sequence": "https://esm.sh/*rope-sequence@1.3.4",
      "prosemirror-history": "https://esm.sh/*prosemirror-history@1.4.0",
      "prosemirror-keymap": "https://esm.sh/*prosemirror-keymap@1.2.2",
      "w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8",
      "prosemirror-commands": "https://esm.sh/*prosemirror-commands@1.5.2"
    }
  }
</script>
<script type="module" src="./main.js"></script>
/* Own
*/
* {
  margin: 0;
  padding: 0;
}
p {
  margin-bottom: 0.5em;
}

/* ProseMirror
*/
.ProseMirror {
  position: relative;
}

.ProseMirror {
  word-wrap: break-word;
  white-space: pre-wrap;
  white-space: break-spaces;
  -webkit-font-variant-ligatures: none;
  font-variant-ligatures: none;
  font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}

.ProseMirror pre {
  white-space: pre-wrap;
}

.ProseMirror li {
  position: relative;
}

.ProseMirror-hideselection *::selection {
  background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
  background: transparent;
}
.ProseMirror-hideselection {
  caret-color: transparent;
}

/* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */
.ProseMirror [draggable][contenteditable="false"] {
  user-select: text;
}

.ProseMirror-selectednode {
  outline: 2px solid #8cf;
}

/* Make sure li selections wrap around markers */

li.ProseMirror-selectednode {
  outline: none;
}

li.ProseMirror-selectednode:after {
  content: "";
  position: absolute;
  left: -32px;
  right: -2px;
  top: -2px;
  bottom: -2px;
  border: 2px solid #8cf;
  pointer-events: none;
}

/* Protect against generic img rules */

img.ProseMirror-separator {
  display: inline !important;
  border: none !important;
  margin: 0 !important;
}

.ProseMirror.virtual-cursor-enabled {
  /* Hide the native cursor */
  caret-color: transparent;
}

.ProseMirror-focused {
  /* Color of the virtual cursor */
  --prosemirror-virtual-cursor-color: blue;
}

.ProseMirror .prosemirror-virtual-cursor {
  position: absolute;
  cursor: text;
  pointer-events: none;
  transform: translate(-1px);
  user-select: none;
  -webkit-user-select: none;
  border-left: 3px solid var(--prosemirror-virtual-cursor-color);
}

.ProseMirror .prosemirror-virtual-cursor-left {
  width: 1ch;
  transform: translate(calc(-1ch + -1px));
  border-bottom: 2px solid var(--prosemirror-virtual-cursor-color);
  border-right: 3px solid var(--prosemirror-virtual-cursor-color);
  border-left: none;
}

.ProseMirror .prosemirror-virtual-cursor-right {
  width: 1ch;
  border-bottom: 3px solid var(--prosemirror-virtual-cursor-color);
  border-left: 3px solid var(--prosemirror-virtual-cursor-color);
  border-right: none;
}

.ProseMirror-focused .prosemirror-virtual-cursor-animation {
  animation: prosemirror-virtual-cursor-blink 1s linear infinite;
  animation-delay: 0.5s;
}
// There is a name collision between prosemirror-model export `DOMParser` and
// the native constructor that we use to parse `html` attribute string value
// to DOM, so we import the prosemirror parser as an alias "PMDOMParser"
import {
  Schema,
  DOMParser as PMDOMParser,
  DOMSerializer,
} from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";

const schema = new Schema({
  nodes: {
    doc: { content: "block+" },
    paragraph: {
      content: "text*",
      group: "block",
      parseDOM: [{ tag: "p" }],
      toDOM() {
        return ["p", 0];
      },
    },
    text: { group: "inline" },
  },
  marks: {
    link: {
      attrs: {
        href: {},
        title: { default: null },
      },
      // By default, marks are inclusive, meaning that they
      // get applied to content inserted at their end (as well
      // as at their start when they start at the start 
      // of their parent node)
      inclusive: false,
      parseDOM: [
        {
          tag: "a[href]",
          getAttrs(dom) {
            return {
              href: dom.getAttribute("href"),
              title: dom.getAttribute("title"),
            };
          },
        },
      ],
      toDOM(node) {
        return ["a", node.attrs, 0];
      },
    },
  },
});

class ProseMirrorEditor extends HTMLElement {
  constructor() {
    super();
    this.editorView = null;
  }

  connectedCallback() {
    this.innerHTML = `<div id="editor"></div>`;
    const editorElement = this.querySelector("#editor");
    const initialContent = this.getAttribute("html");
    const customChangeEventName = this.getAttribute("change-event");

    // default empty paragraph
    let doc = schema.node("doc", null, [schema.node("paragraph")]);

    if (initialContent) {
      // html -> DOM
      const contentElement = new DOMParser().parseFromString(
        initialContent,
        "text/html"
      ).body;
      // DOM -> Prosemirror doc
      doc = PMDOMParser.fromSchema(schema).parse(contentElement);
    }

    const state = EditorState.create({
      doc,
      schema,
      plugins: [
        history(),
        keymap({ "Mod-z": undo, "Mod-y": redo }),
        keymap(baseKeymap),
      ],
    });

    this.editorView = new EditorView(editorElement, {
      state,
      dispatchTransaction: (transaction) => {
	      const newState = this.editorView.state.apply(transaction);
        this.editorView.updateState(newState);
        if (transaction.docChanged) {
          this.dispatchEvent(new CustomEvent(customChangeEventName || 'editor-change', {
            detail: {
              html: getHTMLStringFromState(newState),
              json: newState.doc.toJSON()
            }
          }));
        }
      },
    });
    const toggle = document.createElement('button');
    toggle.innerText = 'toggle link';
    editorElement.before(toggle);
    toggle.addEventListener('click', () => {
    	toggleLink(this.editorView);
    });
  }

  disconnectedCallback() {
    if (this.editorView) {
      this.editorView.destroy();
    }
  }
}

function getHTMLStringFromState (state) {
  const fragment = DOMSerializer.fromSchema(state.schema).serializeFragment(state.doc.content);
  const div = document.createElement("div");
  div.appendChild(fragment);
  return div.innerHTML;
}

import { toggleMark } from "prosemirror-commands";

const linkSchema = schema.marks.link;

function toggleLink(view) {
  const { state, dispatch } = view;
  if (markActive(state, linkSchema)) {
    toggleMark(linkSchema)(state, dispatch)
    return true
  }
  const href = prompt("Link target?");
  toggleMark(linkSchema, { href })(view.state, view.dispatch)
  view.focus();
}

function markActive(state, type) {
	let {from, $from, to, empty} = state.selection
	if (empty) return !!type.isInSet(state.storedMarks || $from.marks())
	else return state.doc.rangeHasMark(from, to, type)
}


customElements.define("prosemirror-editor", ProseMirrorEditor);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.